Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2c15fe9c8 | ||
|
|
9fdf0d2cb3 | ||
|
|
b6325a4f8a | ||
|
|
2588c711a7 | ||
|
|
01782cd9d7 | ||
|
|
ad4ed41559 | ||
|
|
86f9054d5b | ||
|
|
c4d5139a50 | ||
|
|
867f20abec | ||
|
|
7c07b29de5 | ||
|
|
6ee0df8a9c | ||
|
|
33ceab6000 | ||
|
|
26b06fe0ff | ||
|
|
7a5582f4f9 | ||
|
|
f050fbdebc | ||
|
|
e27dc9fe53 | ||
|
|
85587b9b62 | ||
|
|
10d93f310e | ||
|
|
99478c0060 | ||
|
|
ee943d9b3f | ||
|
|
a3c85a01a8 | ||
|
|
81f67c8d7b | ||
|
|
30314311e4 | ||
|
|
2a8c6c87cb | ||
|
|
017258e5b4 |
@@ -817,6 +817,7 @@ jobs:
|
||||
run: ./scripts/policy/run-platform-coverage-gate.sh --base-ref "$COVERAGE_BASE_REF" --profile coverage-windows.txt
|
||||
|
||||
- name: Upload Windows coverage artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-windows
|
||||
|
||||
@@ -10,7 +10,7 @@ SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob
|
||||
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
|
||||
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
|
||||
|
||||
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -33,6 +33,7 @@ help:
|
||||
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
|
||||
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
|
||||
@printf " make skill-context-budget - Check generated Skill drift and common-path context budgets\n"
|
||||
@printf " make multi-im-skill-chain-integrity - Check reviewed IM intents keep one default Skill route\n"
|
||||
@printf " make cli-smoke - Verify help for every public top-level command\n"
|
||||
@printf " make mock-mcp-smoke - Verify HTTP and stdio MCP request/response transport\n"
|
||||
@printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n"
|
||||
@@ -87,6 +88,7 @@ policy: test-auth-legacy-compat
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-multi-im-skill-chain.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
|
||||
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-param-concepts.sh
|
||||
@@ -126,6 +128,9 @@ skill-command-integrity:
|
||||
skill-context-budget:
|
||||
@./scripts/policy/check-skill-context-budget.sh
|
||||
|
||||
multi-im-skill-chain-integrity:
|
||||
@./scripts/policy/check-multi-im-skill-chain.sh
|
||||
|
||||
cli-smoke:
|
||||
@./scripts/policy/check-cli-smoke.sh
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ func newEventCommand() *cobra.Command {
|
||||
RunE: func(c *cobra.Command, _ []string) error { return c.Help() },
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newEventListenIMCommand(),
|
||||
newEventConsumeCommand(),
|
||||
newEventListCommand(),
|
||||
newEventSchemaCommand(),
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"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/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type listenIMOptions struct {
|
||||
Kind string
|
||||
Events []string
|
||||
UserID string
|
||||
OpenDingTalkID string
|
||||
UserQuery string
|
||||
ChatID string
|
||||
ChatQuery string
|
||||
QueryCSV string
|
||||
MaxEvents int
|
||||
Duration time.Duration
|
||||
DryRun bool
|
||||
ControlBaseURL string
|
||||
StreamTicketMode string
|
||||
StreamTicketURL string
|
||||
StreamSourceID string
|
||||
}
|
||||
|
||||
type listenIMPlan struct {
|
||||
EventKeys []string
|
||||
UserID string
|
||||
OpenDingTalkID string
|
||||
GroupID string
|
||||
ResolvedTargets []any
|
||||
}
|
||||
|
||||
type eventTargetReader struct{}
|
||||
|
||||
func (eventTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
|
||||
text, err := helpers.CallMCPReadToolTextOnServer(product, tool, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &data); err != nil {
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
var eventListenIMReader = func() targetresolver.Reader { return eventTargetReader{} }
|
||||
|
||||
func newEventListenIMCommand() *cobra.Command {
|
||||
var opts listenIMOptions
|
||||
cmd := &cobra.Command{
|
||||
Use: "+listen-im",
|
||||
Short: "按 IM 意图解析目标并监听一个或多个个人消息事件",
|
||||
Long: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey," +
|
||||
"自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
plan, err := compileListenIMPlan(eventListenIMReader(), opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event +listen-im: %w", err)
|
||||
}
|
||||
consumeOpts := personalConsumeOptions{
|
||||
EventKey: firstArg(plan.EventKeys),
|
||||
EventKeys: plan.EventKeys,
|
||||
Flatten: true,
|
||||
UserID: plan.UserID,
|
||||
OpenDingTalkID: plan.OpenDingTalkID,
|
||||
GroupID: plan.GroupID,
|
||||
QueryCSV: opts.QueryCSV,
|
||||
ControlBaseURL: opts.ControlBaseURL,
|
||||
StreamTicketMode: opts.StreamTicketMode,
|
||||
StreamTicketURL: opts.StreamTicketURL,
|
||||
StreamSourceID: opts.StreamSourceID,
|
||||
Common: commonConsumeOptions{
|
||||
FormatRaw: "ndjson",
|
||||
MaxEvents: opts.MaxEvents,
|
||||
Duration: opts.Duration,
|
||||
DryRun: opts.DryRun,
|
||||
},
|
||||
}
|
||||
return eventRunPersonalConsume(c, consumeOpts)
|
||||
},
|
||||
}
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&opts.Kind, "kind", "at-me", "监听意图: at-me|sender|group|all-direct|all-group")
|
||||
f.StringSliceVar(&opts.Events, "events", []string{"message"}, "事件种类: message,reaction,read,recall")
|
||||
f.StringVar(&opts.UserID, "user", "", "指定发送人/单聊对端 userId")
|
||||
f.StringVar(&opts.OpenDingTalkID, "open-dingtalk-id", "", "指定发送人/单聊对端 openDingTalkId")
|
||||
f.StringVar(&opts.UserQuery, "user-query", "", "按姓名/花名唯一解析指定发送人")
|
||||
f.StringVar(&opts.ChatID, "chat-id", "", "指定群 openConversationId")
|
||||
f.StringVar(&opts.ChatQuery, "chat-query", "", "按群名唯一解析指定群")
|
||||
f.StringVar(&opts.QueryCSV, "query", "", "消息文本关键词过滤,逗号分隔;仅 message 事件")
|
||||
f.IntVar(&opts.MaxEvents, "max-events", 0, "收到 N 条后退出 (0 = 不限)")
|
||||
f.DurationVar(&opts.Duration, "duration", 0, "运行时长上限 (Go duration,如 30s/5m;0 = 不限)")
|
||||
f.BoolVar(&opts.DryRun, "dry-run", false, "解析目标并打印订阅计划,不创建订阅或连接 bus")
|
||||
f.StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base 派生 /dws")
|
||||
f.StringVar(&opts.StreamTicketMode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")), "个人 Stream 建联模式;默认 normal")
|
||||
f.StringVar(&opts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")), "个人 Stream sourceId;开源版默认 open")
|
||||
f.StringVar(&opts.StreamTicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")), "个人 Stream 取票 URL")
|
||||
hideEventInternalFlags(cmd, "personal-event-base-url", "stream-ticket-mode", "stream-source-id", "stream-ticket-url")
|
||||
cli.AnnotateRuntimeFlagEnum(cmd, "kind", "at-me", "sender", "group", "all-direct", "all-group")
|
||||
cli.AnnotateRuntimeFlagEnum(cmd, "events", "message", "reaction", "read", "recall")
|
||||
cli.AnnotateRuntimeConstraints(cmd, cli.RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"user", "open-dingtalk-id", "user-query", "chat-id", "chat-query"}},
|
||||
})
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "event",
|
||||
Name: "listen_im",
|
||||
CanonicalPath: "event.listen_im",
|
||||
CLIPath: "event +listen-im",
|
||||
PrimaryCLIPath: "event +listen-im",
|
||||
},
|
||||
Description: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey,自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed IM event facade: it deterministically maps kind/events to public personal EventKeys, resolves one natural user/chat target with the shared typed resolver, then delegates one single- or multi-event invocation to the existing subscription, bus, ready-marker, NDJSON, rollback, cancellation, and cleanup lifecycle.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按 @我、姓名、群名或全量范围监听一个或多个 IM 消息事件",
|
||||
UseWhen: []string{
|
||||
"已知要监听 @我、指定发送人、指定群、全部单聊或全部群聊的 message/reaction/read/recall 事件时使用;姓名用 --user-query、群名用 --chat-query,CLI 会唯一解析目标并把多个兼容事件合并到一个消费生命周期。",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"需要群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层 consume 控制时使用 event consume;只查历史消息时使用 chat 查询入口",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws event +listen-im --kind at-me --max-events 1",
|
||||
"dws event +listen-im --kind group --events message,reaction --chat-id <openConversationId> --duration 10m",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "chat-id", Property: "chatId"},
|
||||
{Name: "chat-query", Property: "chatQuery"},
|
||||
{Name: "dry-run", Property: "dryRun"},
|
||||
{Name: "duration", Property: "duration"},
|
||||
{Name: "events", Property: "events"},
|
||||
{Name: "kind", Property: "kind"},
|
||||
{Name: "max-events", Property: "maxEvents"},
|
||||
{Name: "open-dingtalk-id", Property: "openDingtalkId"},
|
||||
{Name: "query", Property: "query"},
|
||||
{Name: "user", Property: "user"},
|
||||
{Name: "user-query", Property: "userQuery"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func compileListenIMPlan(reader targetresolver.Reader, opts listenIMOptions) (listenIMPlan, error) {
|
||||
kind := strings.ToLower(strings.TrimSpace(opts.Kind))
|
||||
if kind == "" {
|
||||
kind = "at-me"
|
||||
}
|
||||
events := uniqueListenIMValues(opts.Events)
|
||||
if len(events) == 0 {
|
||||
return listenIMPlan{}, apperrors.NewValidation("--events 至少包含一个事件种类")
|
||||
}
|
||||
if strings.TrimSpace(opts.QueryCSV) != "" {
|
||||
for _, eventName := range events {
|
||||
if eventName != "message" {
|
||||
return listenIMPlan{}, apperrors.NewValidation("--query 只支持 message 事件")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plan := listenIMPlan{}
|
||||
var err error
|
||||
switch kind {
|
||||
case "at-me", "all-direct", "all-group":
|
||||
if listenIMTargetCount(opts) != 0 {
|
||||
return listenIMPlan{}, apperrors.NewValidation(fmt.Sprintf("--kind %s 不接受用户或群目标", kind))
|
||||
}
|
||||
case "sender":
|
||||
if listenIMUserTargetCount(opts) != 1 || listenIMChatTargetCount(opts) != 0 {
|
||||
return listenIMPlan{}, apperrors.NewValidation("--kind sender 必须且只能指定 --user、--open-dingtalk-id 或 --user-query 之一")
|
||||
}
|
||||
plan.UserID = strings.TrimSpace(opts.UserID)
|
||||
plan.OpenDingTalkID = strings.TrimSpace(opts.OpenDingTalkID)
|
||||
if query := strings.TrimSpace(opts.UserQuery); query != "" {
|
||||
resolved, resolveErr := targetresolver.ResolveUser(reader, query, targetresolver.IdentityAny)
|
||||
if resolveErr != nil {
|
||||
return listenIMPlan{}, resolveErr
|
||||
}
|
||||
plan.ResolvedTargets = append(plan.ResolvedTargets, resolved)
|
||||
plan.UserID = resolved.Selected.UserID
|
||||
if plan.UserID == "" {
|
||||
plan.OpenDingTalkID = resolved.Selected.OpenDingTalkID
|
||||
}
|
||||
}
|
||||
case "group":
|
||||
if listenIMChatTargetCount(opts) != 1 || listenIMUserTargetCount(opts) != 0 {
|
||||
return listenIMPlan{}, apperrors.NewValidation("--kind group 必须且只能指定 --chat-id 或 --chat-query 之一")
|
||||
}
|
||||
plan.GroupID = strings.TrimSpace(opts.ChatID)
|
||||
if query := strings.TrimSpace(opts.ChatQuery); query != "" {
|
||||
resolved, resolveErr := targetresolver.ResolveChat(reader, query)
|
||||
if resolveErr != nil {
|
||||
return listenIMPlan{}, resolveErr
|
||||
}
|
||||
plan.ResolvedTargets = append(plan.ResolvedTargets, resolved)
|
||||
plan.GroupID = resolved.Selected.OpenConversationID
|
||||
}
|
||||
default:
|
||||
return listenIMPlan{}, apperrors.NewValidation("--kind 必须是 at-me、sender、group、all-direct 或 all-group")
|
||||
}
|
||||
|
||||
plan.EventKeys, err = listenIMEventKeys(kind, events)
|
||||
if err != nil {
|
||||
return listenIMPlan{}, err
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func listenIMEventKeys(kind string, events []string) ([]string, error) {
|
||||
mapping := map[string]map[string]string{
|
||||
"at-me": {"message": personal.EventMention},
|
||||
"sender": {"message": personal.EventFromUser, "reaction": personal.EventReactionO2O, "read": personal.EventReadO2O, "recall": personal.EventRecallO2O},
|
||||
"group": {"message": personal.EventInChat, "reaction": personal.EventReactionGroup, "read": personal.EventReadGroup, "recall": personal.EventRecallGroup},
|
||||
"all-direct": {"message": personal.EventAllSingleChat},
|
||||
"all-group": {"message": personal.EventAllGroupChat},
|
||||
}
|
||||
byEvent := mapping[kind]
|
||||
keys := make([]string, 0, len(events))
|
||||
for _, eventName := range events {
|
||||
key := byEvent[eventName]
|
||||
if key == "" {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--kind %s 不支持 event %s", kind, eventName))
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func listenIMUserTargetCount(opts listenIMOptions) int {
|
||||
return nonEmptyListenIMCount(opts.UserID, opts.OpenDingTalkID, opts.UserQuery)
|
||||
}
|
||||
|
||||
func listenIMChatTargetCount(opts listenIMOptions) int {
|
||||
return nonEmptyListenIMCount(opts.ChatID, opts.ChatQuery)
|
||||
}
|
||||
|
||||
func listenIMTargetCount(opts listenIMOptions) int {
|
||||
return listenIMUserTargetCount(opts) + listenIMChatTargetCount(opts)
|
||||
}
|
||||
|
||||
func nonEmptyListenIMCount(values ...string) int {
|
||||
count := 0
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func uniqueListenIMValues(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type listenIMFakeReader struct {
|
||||
responses map[string]map[string]any
|
||||
calls []string
|
||||
}
|
||||
|
||||
type listenIMErrorReader struct{ err error }
|
||||
|
||||
func (r listenIMErrorReader) CallMCPData(string, string, map[string]any) (map[string]any, error) {
|
||||
return nil, r.err
|
||||
}
|
||||
|
||||
type listenIMHelperCaller struct {
|
||||
text string
|
||||
err error
|
||||
}
|
||||
|
||||
func (c listenIMHelperCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
return c.result()
|
||||
}
|
||||
|
||||
func (c listenIMHelperCaller) CallReadTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
return c.result()
|
||||
}
|
||||
|
||||
func (c listenIMHelperCaller) result() (*edition.ToolResult, error) {
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: c.text}}}, nil
|
||||
}
|
||||
|
||||
func (listenIMHelperCaller) Format() string { return "json" }
|
||||
func (listenIMHelperCaller) DryRun() bool { return false }
|
||||
func (listenIMHelperCaller) Fields() string { return "" }
|
||||
func (listenIMHelperCaller) JQ() string { return "" }
|
||||
|
||||
func (f *listenIMFakeReader) CallMCPData(product, tool string, _ map[string]any) (map[string]any, error) {
|
||||
key := product + "/" + tool
|
||||
f.calls = append(f.calls, key)
|
||||
if response, ok := f.responses[key]; ok {
|
||||
return response, nil
|
||||
}
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompileListenIMPlanResolvesGroupAndMapsMultipleEvents(t *testing.T) {
|
||||
reader := &listenIMFakeReader{responses: map[string]map[string]any{
|
||||
"im/search_groups": {
|
||||
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
|
||||
},
|
||||
}}
|
||||
plan, err := compileListenIMPlan(reader, listenIMOptions{
|
||||
Kind: "group",
|
||||
Events: []string{"message", "reaction", "recall"},
|
||||
ChatQuery: "项目群",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantKeys := []string{personal.EventInChat, personal.EventReactionGroup, personal.EventRecallGroup}
|
||||
if !reflect.DeepEqual(plan.EventKeys, wantKeys) || plan.GroupID != "cid-1" {
|
||||
t.Fatalf("plan = %#v, want keys=%v group=cid-1", plan, wantKeys)
|
||||
}
|
||||
if !reflect.DeepEqual(reader.calls, []string{"im/search_groups"}) {
|
||||
t.Fatalf("resolver calls = %#v", reader.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompileListenIMPlanReturnsStructuredAmbiguityBeforeSubscription(t *testing.T) {
|
||||
reader := &listenIMFakeReader{responses: map[string]map[string]any{
|
||||
"contact/search_contact_by_key_word": {
|
||||
"result": []any{
|
||||
map[string]any{"name": "张三", "userId": "u1"},
|
||||
map[string]any{"name": "张三", "userId": "u2"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, err := compileListenIMPlan(reader, listenIMOptions{
|
||||
Kind: "sender",
|
||||
Events: []string{"message"},
|
||||
UserQuery: "张三",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ambiguous sender unexpectedly compiled")
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "resolution_ambiguous" {
|
||||
t.Fatalf("ambiguity error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventListenIMCommandDelegatesOneCompiledConsumeLifecycle(t *testing.T) {
|
||||
reader := &listenIMFakeReader{responses: map[string]map[string]any{
|
||||
"im/search_groups": {
|
||||
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
|
||||
},
|
||||
}}
|
||||
oldReader := eventListenIMReader
|
||||
oldRun := eventRunPersonalConsume
|
||||
t.Cleanup(func() {
|
||||
eventListenIMReader = oldReader
|
||||
eventRunPersonalConsume = oldRun
|
||||
})
|
||||
eventListenIMReader = func() targetresolver.Reader { return reader }
|
||||
var captured personalConsumeOptions
|
||||
var calls int
|
||||
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
|
||||
calls++
|
||||
captured = opts
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := newEventListenIMCommand()
|
||||
cmd.SetArgs([]string{
|
||||
"--kind", "group",
|
||||
"--events", "message,reaction",
|
||||
"--chat-query", "项目群",
|
||||
"--max-events", "2",
|
||||
"--duration", "30s",
|
||||
"--dry-run",
|
||||
})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("consume lifecycle calls = %d, want 1", calls)
|
||||
}
|
||||
if !reflect.DeepEqual(captured.EventKeys, []string{personal.EventInChat, personal.EventReactionGroup}) ||
|
||||
captured.GroupID != "cid-1" || !captured.Flatten || !captured.Common.DryRun ||
|
||||
captured.Common.MaxEvents != 2 || captured.Common.Duration.String() != "30s" {
|
||||
t.Fatalf("captured options = %#v", captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompileListenIMPlanRejectsIncompatibleKindAndTargets(t *testing.T) {
|
||||
reader := &listenIMFakeReader{}
|
||||
cases := []listenIMOptions{
|
||||
{Kind: "at-me", Events: []string{"reaction"}},
|
||||
{Kind: "all-group", Events: []string{"message"}, ChatID: "cid"},
|
||||
{Kind: "sender", Events: []string{"message"}},
|
||||
{Kind: "group", Events: []string{"message"}, ChatID: "cid", ChatQuery: "群"},
|
||||
{Kind: "group", Events: []string{"message", "reaction"}, ChatID: "cid", QueryCSV: "关键词"},
|
||||
}
|
||||
for _, opts := range cases {
|
||||
if _, err := compileListenIMPlan(reader, opts); err == nil {
|
||||
t.Errorf("options unexpectedly accepted: %#v", opts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListenIMCompletionBranches(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
text string
|
||||
err error
|
||||
ok bool
|
||||
}{
|
||||
{name: "transport", err: errors.New("transport")},
|
||||
{name: "empty", text: " ", ok: true},
|
||||
{name: "invalid json", text: "{invalid"},
|
||||
{name: "valid", text: `{"result":{"ok":true}}`, ok: true},
|
||||
} {
|
||||
t.Run("reader "+tc.name, func(t *testing.T) {
|
||||
helpers.InitDeps(listenIMHelperCaller{text: tc.text, err: tc.err})
|
||||
data, err := (eventTargetReader{}).CallMCPData("im", "search_groups", nil)
|
||||
if (err == nil) != tc.ok {
|
||||
t.Fatalf("data=%#v error=%v ok=%v", data, err, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if plan, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Events: []string{" MESSAGE ", "message"}}); err != nil || len(plan.EventKeys) != 1 {
|
||||
t.Fatalf("default/deduplicated plan = %#v, %v", plan, err)
|
||||
}
|
||||
if _, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Kind: "at-me"}); err == nil {
|
||||
t.Fatal("empty event set unexpectedly accepted")
|
||||
}
|
||||
if _, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Kind: "unknown", Events: []string{"message"}}); err == nil {
|
||||
t.Fatal("unknown kind unexpectedly accepted")
|
||||
}
|
||||
|
||||
reader := &listenIMFakeReader{responses: map[string]map[string]any{
|
||||
"contact/search_contact_by_key_word": {
|
||||
"result": []any{map[string]any{"name": "甲", "openDingTalkId": "D-user"}},
|
||||
},
|
||||
}}
|
||||
plan, err := compileListenIMPlan(reader, listenIMOptions{Kind: "sender", Events: []string{"message"}, UserQuery: "甲"})
|
||||
if err != nil || plan.UserID != "" || plan.OpenDingTalkID != "D-user" {
|
||||
t.Fatalf("open-id sender plan = %#v, %v", plan, err)
|
||||
}
|
||||
wantErr := errors.New("resolution failed")
|
||||
if _, err := compileListenIMPlan(listenIMErrorReader{err: wantErr}, listenIMOptions{Kind: "sender", Events: []string{"message"}, UserQuery: "甲"}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("sender resolution error = %v", err)
|
||||
}
|
||||
if _, err := compileListenIMPlan(listenIMErrorReader{err: wantErr}, listenIMOptions{Kind: "group", Events: []string{"message"}, ChatQuery: "群"}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("group resolution error = %v", err)
|
||||
}
|
||||
|
||||
cmd := newEventListenIMCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--kind", "sender"})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "event +listen-im") {
|
||||
t.Fatalf("command compile error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventListenIME2ELifecycleCleansAndRollsBack(t *testing.T) {
|
||||
newReader := func() *listenIMFakeReader {
|
||||
return &listenIMFakeReader{responses: map[string]map[string]any{
|
||||
"im/search_groups": {
|
||||
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
|
||||
},
|
||||
}}
|
||||
}
|
||||
installFacade := func(t *testing.T, reader *listenIMFakeReader) {
|
||||
t.Helper()
|
||||
oldReader := eventListenIMReader
|
||||
oldRun := eventRunPersonalConsume
|
||||
t.Cleanup(func() {
|
||||
eventListenIMReader = oldReader
|
||||
eventRunPersonalConsume = oldRun
|
||||
})
|
||||
eventListenIMReader = func() targetresolver.Reader { return reader }
|
||||
eventRunPersonalConsume = runPersonalEventConsume
|
||||
}
|
||||
installLifecycle := func(t *testing.T) {
|
||||
t.Helper()
|
||||
restore := installPersonalManySeams(t)
|
||||
t.Cleanup(restore)
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{
|
||||
AccessToken: "token", CorpID: "corp", UserID: "user",
|
||||
ClientID: "client", SourceID: "open",
|
||||
}, nil
|
||||
}
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
|
||||
}
|
||||
|
||||
t.Run("ready then clean every created subscription", func(t *testing.T) {
|
||||
reader := newReader()
|
||||
installFacade(t, reader)
|
||||
installLifecycle(t)
|
||||
var created, deleted, removed []string
|
||||
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
created = append(created, opts.EventKey)
|
||||
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}, opts.EventKey, "group", nil
|
||||
}
|
||||
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
|
||||
deleted = append(deleted, id)
|
||||
return nil
|
||||
}
|
||||
personalRemoveRunStates = func(_ string, ids []string) error {
|
||||
removed = append(removed, ids...)
|
||||
return nil
|
||||
}
|
||||
personalConsumeRunMany = func(_ context.Context, cfg consume.Config, specs []consume.ConsumerSpec) error {
|
||||
if len(specs) != 2 || !cfg.Flatten {
|
||||
t.Fatalf("consume specs/config = %#v / %#v", specs, cfg)
|
||||
}
|
||||
fmt.Fprintf(cfg.Stderr, "[event] ready event_count=%d bus_pid=123\n", len(specs))
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := newEventListenIMCommand()
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetArgs([]string{
|
||||
"--kind", "group", "--events", "message,reaction",
|
||||
"--chat-query", "项目群", "--max-events", "1",
|
||||
})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantEvents := []string{personal.EventInChat, personal.EventReactionGroup}
|
||||
if !reflect.DeepEqual(created, wantEvents) {
|
||||
t.Fatalf("created = %#v, want %#v", created, wantEvents)
|
||||
}
|
||||
wantDeleted := []string{"sub-" + personal.EventReactionGroup, "sub-" + personal.EventInChat}
|
||||
if !reflect.DeepEqual(deleted, wantDeleted) || !reflect.DeepEqual(removed, wantDeleted) {
|
||||
t.Fatalf("deleted=%#v removed=%#v want=%#v", deleted, removed, wantDeleted)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "[event] ready event_count=2") {
|
||||
t.Fatalf("missing ready marker: %s", stderr.String())
|
||||
}
|
||||
if !reflect.DeepEqual(reader.calls, []string{"im/search_groups"}) {
|
||||
t.Fatalf("resolver calls = %#v", reader.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("second create failure rolls back first without starting consumer", func(t *testing.T) {
|
||||
reader := newReader()
|
||||
installFacade(t, reader)
|
||||
installLifecycle(t)
|
||||
wantErr := errors.New("second subscription failed")
|
||||
calls := 0
|
||||
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return nil, "", "", wantErr
|
||||
}
|
||||
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "group", nil
|
||||
}
|
||||
var deleted, removed []string
|
||||
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
|
||||
deleted = append(deleted, id)
|
||||
return nil
|
||||
}
|
||||
personalRemoveRunStates = func(_ string, ids []string) error {
|
||||
removed = append(removed, ids...)
|
||||
return nil
|
||||
}
|
||||
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error {
|
||||
t.Fatal("consumer started after partial subscription failure")
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := newEventListenIMCommand()
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs([]string{
|
||||
"--kind", "group", "--events", "message,reaction",
|
||||
"--chat-query", "项目群",
|
||||
})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), wantErr.Error()) {
|
||||
t.Fatalf("error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if !reflect.DeepEqual(deleted, []string{"sub-first"}) || !reflect.DeepEqual(removed, []string{"sub-first"}) {
|
||||
t.Fatalf("rollback deleted=%#v removed=%#v", deleted, removed)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
|
||||
func TestCrossPlatformCoverageEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
event := newEventCommand()
|
||||
markdown := &cobra.Command{Use: "markdown"}
|
||||
@@ -37,7 +37,7 @@ func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
|
||||
leaves = append(leaves, command.Name())
|
||||
}
|
||||
sort.Strings(leaves)
|
||||
want := []string{"consume", "list", "schema", "status", "stop"}
|
||||
want := []string{"+listen-im", "consume", "list", "schema", "status", "stop"}
|
||||
if len(leaves) != len(want) {
|
||||
t.Fatalf("public event leaves = %v, want %v", leaves, want)
|
||||
}
|
||||
@@ -48,7 +48,7 @@ func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginCannotReplaceBuiltInEventCommand(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePluginCannotReplaceBuiltInEventCommand(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
builtIn := newEventCommand()
|
||||
root.AddCommand(builtIn)
|
||||
|
||||
@@ -29,6 +29,7 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
|
||||
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
|
||||
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
|
||||
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
|
||||
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
|
||||
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
|
||||
"chat +category-create": {"chat", "+category-create", "--title", "Fixture Cat", "--yes"},
|
||||
@@ -37,6 +38,7 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
|
||||
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
"chat +search-msg": {"chat", "+search-msg", "--group", "fixture-conversation", "--query", "fixture", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--no-enrich"},
|
||||
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"},
|
||||
"chat +unread-chats": {"chat", "+unread-chats", "--count", "7", "--exclude-muted"},
|
||||
"chat bot find": {"chat", "bot", "find", "--query", "fixture", "--limit", "7"},
|
||||
@@ -119,6 +121,7 @@ var paramAliasNewIMCases = []struct {
|
||||
emitted string
|
||||
canonical string
|
||||
}{
|
||||
{command: "chat +chat-messages", emitted: "chat", canonical: "group"},
|
||||
{command: "chat +bot-find", emitted: "name", canonical: "query"},
|
||||
{command: "chat bot find", emitted: "name", canonical: "query"},
|
||||
{command: "chat +bot-search", emitted: "query", canonical: "name"},
|
||||
@@ -129,6 +132,7 @@ var paramAliasNewIMCases = []struct {
|
||||
{command: "chat +messages-list-unread-conversations", emitted: "limit", canonical: "count"},
|
||||
{command: "chat +messages-list-unread-conversations", emitted: "size", canonical: "count"},
|
||||
{command: "chat +messages-send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
|
||||
{command: "chat +search-msg", emitted: "chat", canonical: "group"},
|
||||
{command: "chat +unread-chats", emitted: "limit", canonical: "count"},
|
||||
{command: "chat +unread-chats", emitted: "size", canonical: "count"},
|
||||
{command: "chat bot search", emitted: "query", canonical: "name"},
|
||||
@@ -169,7 +173,7 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
}
|
||||
|
||||
func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
|
||||
func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
@@ -220,6 +224,7 @@ func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads
|
||||
if ctx == nil {
|
||||
t.Fatal("complete alias command skipped PreParse")
|
||||
}
|
||||
normalizeParamAliasVolatileDefaults(fixture.Command, canonicalCaller, aliasCaller)
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
|
||||
}
|
||||
@@ -252,7 +257,7 @@ func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
activeAliases := 0
|
||||
for _, test := range paramAliasNewIMCases {
|
||||
test := test
|
||||
@@ -292,6 +297,7 @@ func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
if ctx == nil {
|
||||
t.Fatal("complete alias command skipped PreParse")
|
||||
}
|
||||
normalizeParamAliasVolatileDefaults(test.command, canonicalCaller, aliasCaller)
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
|
||||
}
|
||||
@@ -302,6 +308,23 @@ func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// +chat-messages supplies the current wall-clock time when callers omit
|
||||
// --time. Alias equivalence concerns the resolved target and transport shape;
|
||||
// a suite crossing a second boundary must not make that default appear
|
||||
// alias-dependent.
|
||||
func normalizeParamAliasVolatileDefaults(command string, callers ...*paramAliasCaptureCaller) {
|
||||
if command != "chat +chat-messages" {
|
||||
return
|
||||
}
|
||||
for _, caller := range callers {
|
||||
for i := range caller.calls {
|
||||
if caller.calls[i].tool == "list_conversation_message_v2" || caller.calls[i].tool == "list_individual_chat_message" {
|
||||
delete(caller.calls[i].args, "time")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
|
||||
complete, ok := paramAliasCompleteCommands[command]
|
||||
if variants := paramAliasCompleteCommandVariants[command]; variants != nil {
|
||||
|
||||
+41
-14
@@ -413,6 +413,33 @@ func multiProfileErrorPayload(err error) map[string]any {
|
||||
if typed.Operation != "" {
|
||||
payload["operation"] = typed.Operation
|
||||
}
|
||||
if typed.Origin != "" {
|
||||
payload["origin"] = typed.Origin
|
||||
}
|
||||
if typed.FailureStage != "" {
|
||||
payload["stage"] = typed.FailureStage
|
||||
}
|
||||
if typed.ExecutionStarted != nil {
|
||||
payload["execution_started"] = *typed.ExecutionStarted
|
||||
}
|
||||
if typed.RetryableSet {
|
||||
payload["retryable"] = typed.Retryable
|
||||
}
|
||||
if typed.Hint != "" {
|
||||
payload["hint"] = typed.Hint
|
||||
}
|
||||
if len(typed.Actions) > 0 {
|
||||
payload["actions"] = append([]string(nil), typed.Actions...)
|
||||
}
|
||||
if len(typed.Details) > 0 {
|
||||
payload["details"] = typed.Details
|
||||
}
|
||||
if typed.ServerDiag.TraceID != "" {
|
||||
payload["trace_id"] = typed.ServerDiag.TraceID
|
||||
}
|
||||
if typed.ServerDiag.ServerErrorCode != "" {
|
||||
payload["server_error_code"] = typed.ServerDiag.ServerErrorCode
|
||||
}
|
||||
if code := typed.ExitCode(); code != 0 {
|
||||
payload["exitCode"] = code
|
||||
}
|
||||
@@ -678,7 +705,6 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
|
||||
if callResult.IsError {
|
||||
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
|
||||
logBusinessError(r.transport.FileLogger, "mcp_tool_error", invocation, callResult.Content, diag)
|
||||
|
||||
// ClassifyToolResult hook: let the overlay intercept known error
|
||||
// patterns (PAT permission, gateway-auth) before generic handling.
|
||||
@@ -695,14 +721,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
}
|
||||
}
|
||||
|
||||
mcpErr := apperrors.NewAPI(
|
||||
mcpErr := newServerFailureAPIError(
|
||||
extractMCPErrorMessage(callResult),
|
||||
apperrors.WithOperation("tools/call"),
|
||||
apperrors.WithReason("mcp_tool_error"),
|
||||
apperrors.WithServerKey(invocation.CanonicalProduct),
|
||||
apperrors.WithHint("MCP tool returned a business error; check tool parameters and refer to skill documentation."),
|
||||
apperrors.WithServerDiag(diag),
|
||||
"mcp_tool_error",
|
||||
"MCP tool returned a business error; check tool parameters and refer to skill documentation.",
|
||||
invocation.CanonicalProduct,
|
||||
diag,
|
||||
)
|
||||
logBusinessError(r.transport.FileLogger, serverFailureReason(mcpErr, "mcp_tool_error"), invocation, callResult.Content, diag)
|
||||
// PAT scope error in business response: offer human-readable output and retry
|
||||
if isPatScopeError(mcpErr) {
|
||||
scopeErr := extractPatScopeError(mcpErr)
|
||||
@@ -720,14 +746,15 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
|
||||
if bizErr := detectBusinessError(callResult.Content); bizErr != "" {
|
||||
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
|
||||
logBusinessError(r.transport.FileLogger, "business_error", invocation, callResult.Content, diag)
|
||||
return executor.Result{}, apperrors.NewAPI(bizErr,
|
||||
apperrors.WithOperation("tools/call"),
|
||||
apperrors.WithReason("business_error"),
|
||||
apperrors.WithServerKey(invocation.CanonicalProduct),
|
||||
apperrors.WithHint("The API returned a business-level error. Check required parameters and values."),
|
||||
apperrors.WithServerDiag(diag),
|
||||
classifiedErr := newServerFailureAPIError(
|
||||
bizErr,
|
||||
"business_error",
|
||||
"The API returned a business-level error. Check required parameters and values.",
|
||||
invocation.CanonicalProduct,
|
||||
diag,
|
||||
)
|
||||
logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag)
|
||||
return executor.Result{}, classifiedErr
|
||||
}
|
||||
|
||||
invocation.Implemented = true
|
||||
|
||||
@@ -19,9 +19,9 @@ const (
|
||||
publicShortcutCount = 266
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including hidden leaves such as minutes.shortcut_minutes_search.
|
||||
schemaPublishedShortcutCount = 216
|
||||
schemaPublishedShortcutCount = 267
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 215
|
||||
publiclyDeliveredShortcutCount = 266
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
|
||||
product := executeShortcutSchemaQuery(t, "chat")
|
||||
productPayload, _ := product["product"].(map[string]any)
|
||||
if got, want := int(product["count"].(float64)), 129; got != want {
|
||||
if got, want := int(product["count"].(float64)), 180; got != want {
|
||||
t.Fatalf("schema chat count = %d, want %d", got, want)
|
||||
}
|
||||
summaries := schemaContractObjectSlice(productPayload["tools"])
|
||||
@@ -124,8 +124,8 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
shortcutCount++
|
||||
}
|
||||
}
|
||||
if shortcutCount != 47 {
|
||||
t.Fatalf("schema chat shortcut summaries = %d, want 47", shortcutCount)
|
||||
if shortcutCount != 98 {
|
||||
t.Fatalf("schema chat shortcut summaries = %d, want 98", shortcutCount)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
type serverFailureClass struct {
|
||||
message string
|
||||
reason string
|
||||
origin string
|
||||
stage string
|
||||
hint string
|
||||
actions []string
|
||||
}
|
||||
|
||||
func classifyServerFailure(message string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
|
||||
code := strings.ToUpper(strings.TrimSpace(diag.ServerErrorCode))
|
||||
detail := strings.ToLower(strings.TrimSpace(diag.TechnicalDetail))
|
||||
text := strings.ToLower(strings.TrimSpace(message))
|
||||
|
||||
if code == "NETWORK_ERROR" ||
|
||||
strings.Contains(detail, "statuscode.unavailable") ||
|
||||
strings.Contains(detail, "connection refused") {
|
||||
classified := serverFailureClass{
|
||||
message: "MCP 后端依赖暂时不可用",
|
||||
reason: "backend_dependency_unavailable",
|
||||
origin: "mcp_gateway",
|
||||
stage: "backend_dependency",
|
||||
hint: "请求参数无需修改;请使用相同参数稍后重试。持续失败时请提供 Trace ID 排查 MCP 服务。",
|
||||
actions: []string{
|
||||
"使用相同参数重试一次",
|
||||
"持续失败时保留 Trace ID 并排查 MCP 后端依赖",
|
||||
},
|
||||
}
|
||||
if strings.Contains(detail, "querytoolmeta") {
|
||||
classified.message = "MCP 后端元数据服务暂时不可用"
|
||||
classified.stage = "tool_metadata_lookup"
|
||||
}
|
||||
return classified, true
|
||||
}
|
||||
|
||||
if code == "PARAM_ERROR" ||
|
||||
strings.Contains(text, "opencid or cid is required") ||
|
||||
strings.Contains(text, "openconversationid") && strings.Contains(text, "required") {
|
||||
return serverFailureClass{
|
||||
message: message,
|
||||
reason: "invalid_request",
|
||||
origin: "dingtalk_api",
|
||||
stage: "tool_validation",
|
||||
hint: "请求未通过后端参数校验;请核对当前 leaf Help/Schema 和稳定 ID 类型后重试。",
|
||||
}, true
|
||||
}
|
||||
|
||||
return serverFailureClass{}, false
|
||||
}
|
||||
|
||||
func newServerFailureAPIError(
|
||||
message string,
|
||||
fallbackReason string,
|
||||
fallbackHint string,
|
||||
serverKey string,
|
||||
diag apperrors.ServerDiagnostics,
|
||||
) error {
|
||||
opts := []apperrors.Option{
|
||||
apperrors.WithOperation("tools/call"),
|
||||
apperrors.WithReason(fallbackReason),
|
||||
apperrors.WithServerKey(serverKey),
|
||||
apperrors.WithHint(fallbackHint),
|
||||
apperrors.WithServerDiag(diag),
|
||||
}
|
||||
if classified, ok := classifyServerFailure(message, diag); ok {
|
||||
message = classified.message
|
||||
opts = append(opts,
|
||||
apperrors.WithReason(classified.reason),
|
||||
apperrors.WithOrigin(classified.origin),
|
||||
apperrors.WithFailureStage(classified.stage),
|
||||
apperrors.WithHint(classified.hint),
|
||||
apperrors.WithActions(classified.actions...),
|
||||
)
|
||||
}
|
||||
return apperrors.NewAPI(message, opts...)
|
||||
}
|
||||
|
||||
func serverFailureReason(err error, fallback string) string {
|
||||
typed, ok := err.(*apperrors.Error)
|
||||
if ok && strings.TrimSpace(typed.Reason) != "" {
|
||||
return typed.Reason
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageServerFailureClassifierBackendMetadataUnavailable(t *testing.T) {
|
||||
retryable := true
|
||||
err := newServerFailureAPIError(
|
||||
"business error: success=false",
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
apperrors.ServerDiagnostics{
|
||||
TraceID: "trace-local",
|
||||
ServerErrorCode: "NETWORK_ERROR",
|
||||
TechnicalDetail: "调用 McpService.queryToolMeta 失败: status = StatusCode.UNAVAILABLE; connect: Connection refused (111)",
|
||||
ServerRetryable: &retryable,
|
||||
},
|
||||
)
|
||||
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Reason != "backend_dependency_unavailable" || typed.Origin != "mcp_gateway" || typed.FailureStage != "tool_metadata_lookup" {
|
||||
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
|
||||
}
|
||||
if typed.ExecutionStarted != nil {
|
||||
t.Fatalf("execution_started = %v, want unknown until the backend publishes it", typed.ExecutionStarted)
|
||||
}
|
||||
if !typed.RetryableSet || !typed.Retryable {
|
||||
t.Fatalf("retryability = (%v, %v), want explicit true", typed.RetryableSet, typed.Retryable)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(typed.Hint), "parameter") || strings.Contains(typed.Hint, "认证") {
|
||||
t.Fatalf("misleading hint = %q", typed.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *testing.T) {
|
||||
err := newServerFailureAPIError(
|
||||
"openCid or cid is required",
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"chat",
|
||||
apperrors.ServerDiagnostics{ServerErrorCode: "1001"},
|
||||
)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Reason != "invalid_request" || typed.FailureStage != "tool_validation" {
|
||||
t.Fatalf("classification = reason %q stage %q", typed.Reason, typed.FailureStage)
|
||||
}
|
||||
if typed.ExecutionStarted != nil {
|
||||
t.Fatalf("execution_started = %v, want unknown until the backend publishes it", typed.ExecutionStarted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing.T) {
|
||||
err := newServerFailureAPIError(
|
||||
"business error: success=false",
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
apperrors.ServerDiagnostics{},
|
||||
)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Reason != "business_error" || typed.Origin != "" || typed.FailureStage != "" || typed.ExecutionStarted != nil {
|
||||
t.Fatalf("unexpected fallback classification: %#v", typed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *testing.T) {
|
||||
err := newServerFailureAPIError(
|
||||
"business error: success=false",
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
apperrors.ServerDiagnostics{ServerErrorCode: "NETWORK_ERROR"},
|
||||
)
|
||||
if got := serverFailureReason(err, "business_error"); got != "backend_dependency_unavailable" {
|
||||
t.Fatalf("reason = %q", got)
|
||||
}
|
||||
if got := serverFailureReason(errors.New("plain"), "fallback"); got != "fallback" {
|
||||
t.Fatalf("fallback reason = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesFailureSemantics(t *testing.T) {
|
||||
retryable := true
|
||||
err := newServerFailureAPIError(
|
||||
"business error: success=false",
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
apperrors.ServerDiagnostics{
|
||||
TraceID: "trace-multi",
|
||||
ServerErrorCode: "NETWORK_ERROR",
|
||||
TechnicalDetail: "McpService.queryToolMeta: StatusCode.UNAVAILABLE",
|
||||
ServerRetryable: &retryable,
|
||||
},
|
||||
)
|
||||
payload := multiProfileErrorPayload(err)
|
||||
for key, want := range map[string]any{
|
||||
"reason": "backend_dependency_unavailable",
|
||||
"origin": "mcp_gateway",
|
||||
"stage": "tool_metadata_lookup",
|
||||
"retryable": true,
|
||||
"trace_id": "trace-multi",
|
||||
"server_error_code": "NETWORK_ERROR",
|
||||
} {
|
||||
if got := payload[key]; got != want {
|
||||
t.Errorf("payload[%q] = %#v, want %#v", key, got, want)
|
||||
}
|
||||
}
|
||||
if _, ok := payload["execution_started"]; ok {
|
||||
t.Fatalf("payload must not invent execution_started: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesResolutionDetails(t *testing.T) {
|
||||
err := apperrors.NewValidation(
|
||||
"群目标不唯一",
|
||||
apperrors.WithReason("resolution_ambiguous"),
|
||||
apperrors.WithOrigin("client"),
|
||||
apperrors.WithFailureStage("target_resolution"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithHint("请选择候选"),
|
||||
apperrors.WithActions("使用稳定 ID 重试"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"type": "resolution",
|
||||
"candidates": []string{"cid-1", "cid-2"},
|
||||
}),
|
||||
)
|
||||
payload := multiProfileErrorPayload(err)
|
||||
details, ok := payload["details"].(map[string]any)
|
||||
if !ok || details["type"] != "resolution" {
|
||||
t.Fatalf("details = %#v", payload["details"])
|
||||
}
|
||||
if payload["execution_started"] != false || payload["origin"] != "client" || payload["stage"] != "target_resolution" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if actions, ok := payload["actions"].([]string); !ok || len(actions) != 1 {
|
||||
t.Fatalf("actions = %#v", payload["actions"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFailure(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": request.ID,
|
||||
"result": map[string]any{
|
||||
"structuredContent": map[string]any{
|
||||
"success": false,
|
||||
"code": "NETWORK_ERROR",
|
||||
"trace_id": "trace-replay",
|
||||
"technical_detail": "调用 McpService.queryToolMeta 失败: status = StatusCode.UNAVAILABLE; connect: Connection refused (111)",
|
||||
"retryable": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := transport.NewClient(server.Client())
|
||||
client.TrustedDomains = []string{strings.TrimPrefix(server.URL, "http://")}
|
||||
runner := &runtimeRunner{
|
||||
transport: client,
|
||||
globalFlags: &GlobalFlags{Token: "local-test-token"},
|
||||
}
|
||||
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
|
||||
CanonicalProduct: "im",
|
||||
Tool: "list_conversations",
|
||||
Params: map[string]any{"pageSize": 100},
|
||||
})
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("executeInvocation() error = %T %v, want typed API error", err, err)
|
||||
}
|
||||
if typed.Reason != "backend_dependency_unavailable" || typed.Origin != "mcp_gateway" || typed.FailureStage != "tool_metadata_lookup" {
|
||||
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
|
||||
}
|
||||
if typed.ServerDiag.TraceID != "trace-replay" || !typed.RetryableSet || !typed.Retryable {
|
||||
t.Fatalf("diagnostics = %#v retryable=(%v,%v)", typed.ServerDiag, typed.RetryableSet, typed.Retryable)
|
||||
}
|
||||
if typed.ExecutionStarted != nil {
|
||||
t.Fatalf("execution_started must remain unknown: %v", typed.ExecutionStarted)
|
||||
}
|
||||
}
|
||||
@@ -23,11 +23,11 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/profilectx"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
@@ -117,23 +117,14 @@ type Profile struct {
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
runtimeProfileMu sync.RWMutex
|
||||
runtimeProfile string
|
||||
)
|
||||
|
||||
// SetRuntimeProfile sets a process-local one-shot profile override.
|
||||
func SetRuntimeProfile(profile string) {
|
||||
runtimeProfileMu.Lock()
|
||||
defer runtimeProfileMu.Unlock()
|
||||
runtimeProfile = strings.TrimSpace(profile)
|
||||
profilectx.Set(profile)
|
||||
}
|
||||
|
||||
// RuntimeProfile returns the process-local one-shot profile override.
|
||||
func RuntimeProfile() string {
|
||||
runtimeProfileMu.RLock()
|
||||
defer runtimeProfileMu.RUnlock()
|
||||
return runtimeProfile
|
||||
return profilectx.Get()
|
||||
}
|
||||
|
||||
// ProfilesPath returns the profile metadata path for a config dir.
|
||||
|
||||
@@ -158,6 +158,12 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat +chat-messages",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat +chat-mute",
|
||||
Aliases: map[string]string{
|
||||
@@ -336,6 +342,12 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"at-user-ids": "at-users",
|
||||
},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat +search-msg",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat +send-to-group",
|
||||
Aliases: map[string]string{
|
||||
|
||||
@@ -57,6 +57,8 @@
|
||||
"mail message search": {"scoped_aliases": {"subject": "query"}, "scope_strict": true, "note": "never globalize: mail template create has a real and different --subject"},
|
||||
"calendar event list": {"scoped_aliases": {"date": "start"}, "note": "reviewed against ParseISOTimeToMillis and final list_calendar_events payload; --date is normalized centrally while the command's existing hidden compatibility flags remain native fallbacks"},
|
||||
"chat +bot-find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search shortcut, --name and --query denote the same search keyword; --name must not become a global search alias."},
|
||||
"chat +chat-messages": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: --chat carries one stable openConversationId and normalizes to the existing --group identifier route."},
|
||||
"chat +search-msg": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: scalar --chat carries one stable openConversationId and normalizes to the existing scalar --group filter."},
|
||||
"chat bot find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search command, --name and --query denote the same search keyword; --name must not become a global search alias."},
|
||||
"chat +bot-search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."},
|
||||
"chat bot search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."},
|
||||
@@ -255,7 +257,9 @@
|
||||
{"command": "chat +unread-chats", "emitted": "limit", "expect": "count", "via": "override:scoped"},
|
||||
{"command": "chat +unread-chats", "emitted": "size", "expect": "count", "via": "override:scoped"},
|
||||
{"command": "chat category rename", "emitted": "name", "expect": "title", "via": "override:scoped"},
|
||||
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"}
|
||||
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"},
|
||||
{"command": "chat +chat-messages", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"},
|
||||
{"command": "chat +search-msg", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1142,6 +1142,12 @@ func inferredRuntimeFlagFormat(flag *pflag.Flag) string {
|
||||
}
|
||||
usage := strings.ToLower(strings.TrimSpace(flag.Usage))
|
||||
if strings.Contains(usage, "iso-8601") || strings.Contains(usage, "rfc3339") {
|
||||
// JSON Schema's date-time format means one RFC3339 value. Do not publish
|
||||
// that narrower wire contract when the CLI also accepts local timestamps
|
||||
// or date-only values alongside RFC3339.
|
||||
if strings.Contains(usage, "yyyy-mm-dd") {
|
||||
return ""
|
||||
}
|
||||
return "date-time"
|
||||
}
|
||||
if strings.Contains(usage, "a1") {
|
||||
|
||||
@@ -171,62 +171,4 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
|
||||
"todo task remove-attachment",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "chat-shortcuts-pending-schema-curation",
|
||||
Reason: "These reviewed public Chat shortcuts remain executable and discoverable through the Shortcut catalog while their typed Schema selection and metadata records are curated; exact paths keep reverse completeness strict without hiding future shortcuts.",
|
||||
Reviewed: true,
|
||||
Commands: []string{
|
||||
"chat +category-add-conversation",
|
||||
"chat +category-list-conversations",
|
||||
"chat +category-remove-conversation",
|
||||
"chat +chat-add-bot",
|
||||
"chat +chat-audit-join",
|
||||
"chat +chat-create",
|
||||
"chat +chat-list",
|
||||
"chat +chat-get-by-id",
|
||||
"chat +chat-members-get",
|
||||
"chat +chat-members-list",
|
||||
"chat +chat-mute-member",
|
||||
"chat +chat-quit",
|
||||
"chat +chat-remove-bot",
|
||||
"chat +chat-role-remove",
|
||||
"chat +chat-role-remove-user",
|
||||
"chat +chat-transfer-owner",
|
||||
"chat +chat-update",
|
||||
"chat +chat-update-icon",
|
||||
"chat +chat-update-settings",
|
||||
"chat +conversation-clear-messages",
|
||||
"chat +conversation-clear-red-point",
|
||||
"chat +conversation-hide",
|
||||
"chat +conversation-mark-read",
|
||||
"chat +conversation-mark-unread",
|
||||
"chat +conversation-mute",
|
||||
"chat +conversation-set-top",
|
||||
"chat +feed-group-query-item",
|
||||
"chat +flag-cancel",
|
||||
"chat +flag-create",
|
||||
"chat +flag-list",
|
||||
"chat +messages-add-emoji",
|
||||
"chat +messages-add-text-emotion",
|
||||
"chat +messages-batch-recall-by-bot",
|
||||
"chat +messages-batch-send-by-bot",
|
||||
"chat +messages-combine-forward",
|
||||
"chat +messages-create-text-emotion",
|
||||
"chat +messages-forward",
|
||||
"chat +messages-forward-topic",
|
||||
"chat +messages-list",
|
||||
"chat +messages-recall",
|
||||
"chat +messages-recall-by-bot",
|
||||
"chat +messages-remove-emoji",
|
||||
"chat +messages-remove-text-emotion",
|
||||
"chat +messages-reply",
|
||||
"chat +messages-resource-download",
|
||||
"chat +messages-resource-url",
|
||||
"chat +messages-send-by-bot",
|
||||
"chat +messages-set-pin",
|
||||
"chat +messages-set-top",
|
||||
"chat +messages-unset-pin",
|
||||
"chat +messages-unset-top",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+15
-10
@@ -103,11 +103,12 @@ const ValidationShortcut FlagValidationMode = "shortcut"
|
||||
// fields intentionally mirror the former helpers.LeafFlag one-for-one so that
|
||||
// helpers can alias to it without touching any call site.
|
||||
type FlagSpec struct {
|
||||
Name string // flag name (kebab-case)
|
||||
Usage string // registration usage text
|
||||
Kind FlagKind // value type, defaults to KindString
|
||||
Default string // registration default for every Kind; also the fallback-chain tail when aliases/env are empty
|
||||
Hidden bool // hide the real flag from help/Schema while keeping it invocable
|
||||
Name string // flag name (kebab-case)
|
||||
Shorthand string // optional one-character Cobra shorthand
|
||||
Usage string // registration usage text
|
||||
Kind FlagKind // value type, defaults to KindString
|
||||
Default string // registration default for every Kind; also the fallback-chain tail when aliases/env are empty
|
||||
Hidden bool // hide the real flag from help/Schema while keeping it invocable
|
||||
|
||||
// Required, when true, validates a non-empty effective value in RunE. Plain
|
||||
// Required flags aggregate into a cmdutil.ValidateRequiredFlags-compatible
|
||||
@@ -551,7 +552,7 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
|
||||
"flag %q: MarkRequired cannot be combined with Aliases: cobra MarkFlagRequired only recognizes the main name, so a value passed via an alias would be rejected",
|
||||
flag.Name))
|
||||
}
|
||||
RegisterFlag(cmd, flag.Kind, flag.Name, flag.Default, flag.Usage)
|
||||
registerFlagP(cmd, flag.Kind, flag.Name, flag.Shorthand, flag.Default, flag.Usage)
|
||||
// Aliases are registered with the main flag's Kind, otherwise an integer
|
||||
// alias's value would never be readable (silently dropped).
|
||||
for _, alias := range flag.Aliases {
|
||||
@@ -572,6 +573,10 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
|
||||
// Malformed KindInt / KindBool Default values panic at registration (fail-closed)
|
||||
// instead of silently degrading to 0 / false.
|
||||
func RegisterFlag(cmd *cobra.Command, kind FlagKind, name, def, usage string) {
|
||||
registerFlagP(cmd, kind, name, "", def, usage)
|
||||
}
|
||||
|
||||
func registerFlagP(cmd *cobra.Command, kind FlagKind, name, shorthand, def, usage string) {
|
||||
switch kind {
|
||||
case KindInt:
|
||||
defInt := 0
|
||||
@@ -582,7 +587,7 @@ func RegisterFlag(cmd *cobra.Command, kind FlagKind, name, def, usage string) {
|
||||
}
|
||||
defInt = v
|
||||
}
|
||||
cmd.Flags().Int(name, defInt, usage)
|
||||
cmd.Flags().IntP(name, shorthand, defInt, usage)
|
||||
case KindBool:
|
||||
defBool := false
|
||||
if def != "" {
|
||||
@@ -595,15 +600,15 @@ func RegisterFlag(cmd *cobra.Command, kind FlagKind, name, def, usage string) {
|
||||
panic(fmt.Sprintf("flag %q: invalid KindBool Default %q (want \"true\" or \"false\")", name, def))
|
||||
}
|
||||
}
|
||||
cmd.Flags().Bool(name, defBool, usage)
|
||||
cmd.Flags().BoolP(name, shorthand, defBool, usage)
|
||||
case KindStringSlice:
|
||||
var defaults []string
|
||||
if value := strings.TrimSpace(def); value != "" {
|
||||
defaults = strings.Split(value, ",")
|
||||
}
|
||||
cmd.Flags().StringSlice(name, defaults, usage)
|
||||
cmd.Flags().StringSliceP(name, shorthand, defaults, usage)
|
||||
default:
|
||||
cmd.Flags().String(name, def, usage)
|
||||
cmd.Flags().StringP(name, shorthand, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,10 +67,10 @@ func testDestructiveSafety() contract.SafetySpec {
|
||||
func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
|
||||
cmd := newTestCommand()
|
||||
RegisterFlags(cmd, []FlagSpec{
|
||||
{Name: "s", Usage: "S", Default: "d"},
|
||||
{Name: "i", Usage: "I", Kind: KindInt, Aliases: []string{"i-alias"}},
|
||||
{Name: "b", Usage: "B", Kind: KindBool},
|
||||
{Name: "sl", Usage: "SL", Kind: KindStringSlice, Default: "a,b", Aliases: []string{"sl-alias"}},
|
||||
{Name: "s", Shorthand: "s", Usage: "S", Default: "d"},
|
||||
{Name: "i", Shorthand: "i", Usage: "I", Kind: KindInt, Aliases: []string{"i-alias"}},
|
||||
{Name: "b", Shorthand: "b", Usage: "B", Kind: KindBool},
|
||||
{Name: "sl", Shorthand: "l", Usage: "SL", Kind: KindStringSlice, Default: "a,b", Aliases: []string{"sl-alias"}},
|
||||
{Name: "req", Usage: "R", MarkRequired: true},
|
||||
{Name: "hidden", Usage: "H", Hidden: true},
|
||||
})
|
||||
@@ -78,6 +78,11 @@ func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
|
||||
if f := cmd.Flags().Lookup("s"); f == nil || f.DefValue != "d" || f.Usage != "S" {
|
||||
t.Fatalf("string flag = %#v", f)
|
||||
}
|
||||
for shorthand, name := range map[string]string{"s": "s", "i": "i", "b": "b", "l": "sl"} {
|
||||
if flag := cmd.Flags().ShorthandLookup(shorthand); flag == nil || flag.Name != name {
|
||||
t.Fatalf("shorthand -%s = %#v, want --%s", shorthand, flag, name)
|
||||
}
|
||||
}
|
||||
for name, wantType := range map[string]string{"i": "int", "b": "bool", "sl": "stringSlice"} {
|
||||
f := cmd.Flags().Lookup(name)
|
||||
if f == nil || f.Value.Type() != wantType {
|
||||
|
||||
@@ -46,6 +46,9 @@ type Error struct {
|
||||
Message string
|
||||
Operation string
|
||||
ServerKey string
|
||||
Origin string
|
||||
FailureStage string
|
||||
ExecutionStarted *bool
|
||||
Retryable bool
|
||||
RetryableSet bool
|
||||
RetryAfterSeconds *int64
|
||||
@@ -55,6 +58,7 @@ type Error struct {
|
||||
Actions []string
|
||||
AvailableFlags []string
|
||||
Snapshot string
|
||||
Details map[string]any
|
||||
RPCCode int `json:"rpc_code,omitempty"`
|
||||
RPCData json.RawMessage `json:"rpc_data,omitempty"`
|
||||
ServerDiag ServerDiagnostics `json:"-"`
|
||||
@@ -107,6 +111,32 @@ func WithServerKey(serverKey string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithOrigin records the component that produced the failure, such as the
|
||||
// client, MCP gateway, or DingTalk API. It is independent from Category,
|
||||
// which remains the stable exit-code contract.
|
||||
func WithOrigin(origin string) Option {
|
||||
return func(err *Error) {
|
||||
err.Origin = strings.TrimSpace(origin)
|
||||
}
|
||||
}
|
||||
|
||||
// WithFailureStage records the execution stage at which the failure occurred.
|
||||
func WithFailureStage(stage string) Option {
|
||||
return func(err *Error) {
|
||||
err.FailureStage = strings.TrimSpace(stage)
|
||||
}
|
||||
}
|
||||
|
||||
// WithExecutionStarted records whether the downstream business operation was
|
||||
// known to have started. Unknown state must be represented by omitting this
|
||||
// option, which is important for safe retry decisions on write operations.
|
||||
func WithExecutionStarted(started bool) Option {
|
||||
return func(err *Error) {
|
||||
value := started
|
||||
err.ExecutionStarted = &value
|
||||
}
|
||||
}
|
||||
|
||||
// WithRetryable marks whether the error can be retried safely.
|
||||
func WithRetryable(retryable bool) Option {
|
||||
return func(err *Error) {
|
||||
@@ -186,6 +216,21 @@ func WithSnapshot(path string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithDetails records an additive machine-readable payload for errors whose
|
||||
// recovery needs typed context, such as ambiguous target-resolution
|
||||
// candidates. Callers must keep credentials and other secrets out of details.
|
||||
func WithDetails(details map[string]any) Option {
|
||||
return func(err *Error) {
|
||||
if len(details) == 0 {
|
||||
return
|
||||
}
|
||||
err.Details = make(map[string]any, len(details))
|
||||
for key, value := range details {
|
||||
err.Details[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRPCCode records the original JSON-RPC error code.
|
||||
func WithRPCCode(code int) Option {
|
||||
return func(err *Error) {
|
||||
@@ -295,6 +340,15 @@ func PrintJSON(w io.Writer, err error) error {
|
||||
if typed.ServerKey != "" {
|
||||
errorPayload["server_key"] = typed.ServerKey
|
||||
}
|
||||
if typed.Origin != "" {
|
||||
errorPayload["origin"] = typed.Origin
|
||||
}
|
||||
if typed.FailureStage != "" {
|
||||
errorPayload["stage"] = typed.FailureStage
|
||||
}
|
||||
if typed.ExecutionStarted != nil {
|
||||
errorPayload["execution_started"] = *typed.ExecutionStarted
|
||||
}
|
||||
if typed.RetryableSet {
|
||||
errorPayload["retryable"] = typed.Retryable
|
||||
}
|
||||
@@ -316,6 +370,9 @@ func PrintJSON(w io.Writer, err error) error {
|
||||
if typed.Snapshot != "" {
|
||||
errorPayload["snapshot_path"] = typed.Snapshot
|
||||
}
|
||||
if len(typed.Details) > 0 {
|
||||
errorPayload["details"] = typed.Details
|
||||
}
|
||||
if typed.RPCCode != 0 {
|
||||
errorPayload["rpc_code"] = typed.RPCCode
|
||||
}
|
||||
@@ -447,6 +504,15 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
|
||||
if typed.ServerKey != "" {
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Server: %s", typed.ServerKey)))
|
||||
}
|
||||
if typed.Origin != "" {
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Origin: %s", typed.Origin)))
|
||||
}
|
||||
if typed.FailureStage != "" {
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Stage: %s", typed.FailureStage)))
|
||||
}
|
||||
if typed.ExecutionStarted != nil {
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Execution Started: %t", *typed.ExecutionStarted)))
|
||||
}
|
||||
if typed.Snapshot != "" {
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Snapshot: %s", typed.Snapshot)))
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExitCodeByCategory(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExitCodeByCategory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
@@ -42,17 +42,24 @@ func TestExitCodeByCategory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJSON(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewValidation(
|
||||
"bad flag",
|
||||
WithReason("missing_required_flag"),
|
||||
WithOrigin("client"),
|
||||
WithFailureStage("request_validation"),
|
||||
WithExecutionStarted(false),
|
||||
WithHint("Pass the required flag and retry."),
|
||||
WithRetryable(true),
|
||||
WithActions("dws schema doc.create_document", "retry command"),
|
||||
WithSnapshot("/tmp/dws-recovery/snapshot.json"),
|
||||
WithDetails(map[string]any{
|
||||
"type": "resolution",
|
||||
"query": "项目群",
|
||||
}),
|
||||
)); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
@@ -67,6 +74,11 @@ func TestPrintJSON(t *testing.T) {
|
||||
if !strings.Contains(got, "\"reason\": \"missing_required_flag\"") {
|
||||
t.Fatalf("expected reason in output, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "\"origin\": \"client\"") ||
|
||||
!strings.Contains(got, "\"stage\": \"request_validation\"") ||
|
||||
!strings.Contains(got, "\"execution_started\": false") {
|
||||
t.Fatalf("expected failure provenance in output, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "\"retryable\": true") {
|
||||
t.Fatalf("expected retryable in output, got %q", got)
|
||||
}
|
||||
@@ -76,6 +88,9 @@ func TestPrintJSON(t *testing.T) {
|
||||
if !strings.Contains(got, "\"snapshot_path\": \"/tmp/dws-recovery/snapshot.json\"") {
|
||||
t.Fatalf("expected snapshot path in output, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "\"type\": \"resolution\"") || !strings.Contains(got, "\"query\": \"项目群\"") {
|
||||
t.Fatalf("expected structured details in output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRetryabilityTriStateAndRetryTiming(t *testing.T) {
|
||||
@@ -171,7 +186,7 @@ func TestCrossPlatformCoverageRetryTimingOptionsIgnoreInvalidValues(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJSON_AvailableFlags(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintJSON_AvailableFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
@@ -192,7 +207,7 @@ func TestPrintJSON_AvailableFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintHuman(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintHuman(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
@@ -201,6 +216,9 @@ func TestPrintHuman(t *testing.T) {
|
||||
WithReason("missing_required_flag"),
|
||||
WithOperation("calendar.list"),
|
||||
WithServerKey("calendar"),
|
||||
WithOrigin("client"),
|
||||
WithFailureStage("request_validation"),
|
||||
WithExecutionStarted(false),
|
||||
WithHint("Pass the required flag and retry."),
|
||||
WithRetryable(true),
|
||||
WithActions("retry command"),
|
||||
@@ -228,9 +246,19 @@ func TestPrintHuman(t *testing.T) {
|
||||
if !strings.Contains(got, "Retryable: true") {
|
||||
t.Fatalf("expected retryable marker in output, got %q", got)
|
||||
}
|
||||
for _, want := range []string{"Origin: client", "Stage: request_validation", "Execution Started: false"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("expected %q in verbose output, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
withoutDetails := NewValidation("empty", WithDetails(nil)).(*Error)
|
||||
if withoutDetails.Details != nil {
|
||||
t.Fatalf("empty details were retained: %#v", withoutDetails.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintHuman_NormalMode(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintHuman_NormalMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
@@ -254,7 +282,7 @@ func TestPrintHuman_NormalMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJSONIncludesServerDiag(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintJSONIncludesServerDiag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
@@ -289,7 +317,7 @@ func TestPrintJSONIncludesServerDiag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintHumanIncludesServerGuidance(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintHumanIncludesServerGuidance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
@@ -313,7 +341,7 @@ func TestPrintHumanIncludesServerGuidance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJSONIncludesRPCCodeAndData(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
@@ -335,7 +363,7 @@ func TestPrintJSONIncludesRPCCodeAndData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintHumanIncludesRPCCode_Debug(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintHumanIncludesRPCCode_Debug(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
@@ -356,7 +384,7 @@ func TestPrintHumanIncludesRPCCode_Debug(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintHumanHidesRPCCode_Normal(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintHumanHidesRPCCode_Normal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
@@ -3,6 +3,7 @@ package helpers
|
||||
import (
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
@@ -78,6 +79,8 @@ func addAisearchPersonFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().String(alias, "", "")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
cmd.Flags().String("type", "", "兼容选择器;person/search 路径仅接受 person/user/people")
|
||||
_ = cmd.Flags().MarkHidden("type")
|
||||
}
|
||||
|
||||
func addAisearchKeywordCompatibilityFlag(cmd *cobra.Command) {
|
||||
@@ -90,6 +93,9 @@ func addAisearchKeywordCompatibilityFlag(cmd *cobra.Command) {
|
||||
// runAisearchPerson 是 aisearch person 的实际执行体,被 personCmd 和 root
|
||||
// 的智能 RunE(裸调兜底)共享调用。
|
||||
func runAisearchPerson(cmd *cobra.Command, _ []string) error {
|
||||
if selector := strings.ToLower(strings.TrimSpace(flagValue(cmd, "type"))); selector != "" && selector != "person" && selector != "user" && selector != "people" {
|
||||
return apperrors.NewValidation("aisearch person/search 的 --type 仅接受 person、user 或 people")
|
||||
}
|
||||
keyword := resolveAisearchKeyword(cmd)
|
||||
if keyword == "" {
|
||||
// 复用原有报错文案("keyword is required")
|
||||
|
||||
@@ -28,3 +28,13 @@ func TestCrossPlatformCoverageAISearchRemainingFallbackBranches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAisearchPersonAcceptsRedundantTypeSelector(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executeFilterCoverage(t, newAisearchCommand(), "search", "--query", "张三", "--type", "person"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := executeFilterCoverage(t, newAisearchCommand(), "search", "--query", "张三", "--type", "document"); err == nil {
|
||||
t.Fatal("invalid person type selector unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
+129
-48
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -53,6 +54,35 @@ func resolveMessageForward(cmd *cobra.Command, defaultForward bool) (bool, error
|
||||
}
|
||||
}
|
||||
|
||||
type nativeChatTargetReader struct{}
|
||||
|
||||
func (nativeChatTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
|
||||
text, err := CallMCPReadToolTextOnServer(product, tool, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &data); err != nil {
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func resolveNativeChatTarget(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if targetresolver.LooksLikeOpenConversationID(raw) {
|
||||
return raw, nil
|
||||
}
|
||||
resolved, err := targetresolver.ResolveChat(nativeChatTargetReader{}, raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolved.Selected.OpenConversationID, nil
|
||||
}
|
||||
|
||||
const maxConversationCategoryTitleRunes = 15
|
||||
|
||||
func validatedConversationCategoryTitle(raw string) (string, error) {
|
||||
@@ -78,6 +108,61 @@ func chatIntFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string
|
||||
return v
|
||||
}
|
||||
|
||||
func runChatGroupSearch(cmd *cobra.Command, args []string) error {
|
||||
keyword := flagOrFallback(cmd, "query", "keyword", "name", "group")
|
||||
if len(args) == 1 {
|
||||
if keyword != "" {
|
||||
return apperrors.NewValidation("群搜索位置参数与 --query/--keyword 不能同时指定")
|
||||
}
|
||||
keyword = strings.TrimSpace(args[0])
|
||||
}
|
||||
if keyword == "" {
|
||||
return apperrors.NewValidation("flag --query is required\n hint: dws chat search --query \"test\"")
|
||||
}
|
||||
limit := chatIntFlagOrFallback(cmd, "limit", "size")
|
||||
cursor, _ := cmd.Flags().GetString("cursor")
|
||||
toolArgs := map[string]any{
|
||||
"keyword": keyword,
|
||||
"limit": limit,
|
||||
"cursor": cursor,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("exclude-muted"); v {
|
||||
toolArgs["excludeMuted"] = true
|
||||
}
|
||||
return callMCPToolOnServer("im", "search_groups", toolArgs)
|
||||
}
|
||||
|
||||
func newChatGroupSearchCommand(hidden bool) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "search [query]",
|
||||
Short: "根据关键词搜索群聊",
|
||||
Hidden: hidden,
|
||||
Long: `根据关键词搜索群聊列表。分页参数 --limit(默认 20)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。
|
||||
|
||||
注意:
|
||||
1. query 不要拆分得太细,应使用群名称中连续的核心词作为关键词(如群名"项目冲刺群"应搜"项目冲刺"而非拆成"项目"+"冲刺"分别搜索)。
|
||||
2. 当搜索结果返回多个群聊时,应列出候选群让用户确认目标群聊,不要自行假定并直接进行后续操作。`,
|
||||
Example: ` dws chat search --query "项目冲刺"
|
||||
dws chat search "项目冲刺"
|
||||
dws chat search --query "项目冲刺" --limit 20 --cursor 0`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runChatGroupSearch,
|
||||
}
|
||||
cmd.Flags().String("query", "", "搜索关键词 (必填)")
|
||||
cmd.Flags().String("keyword", "", "--query 的别名")
|
||||
_ = cmd.Flags().MarkHidden("keyword")
|
||||
cmd.Flags().String("name", "", "--query 的兼容别名")
|
||||
_ = cmd.Flags().MarkHidden("name")
|
||||
cmd.Flags().String("group", "", "--query 的兼容别名")
|
||||
_ = cmd.Flags().MarkHidden("group")
|
||||
cmd.Flags().Int("limit", 20, "每页返回数量(默认 20)")
|
||||
cmd.Flags().Int("size", 0, "--limit 的旧版别名")
|
||||
_ = cmd.Flags().MarkHidden("size")
|
||||
cmd.Flags().String("cursor", "0", "分页游标(默认 \"0\",翻页传 nextCursor)")
|
||||
cmd.Flags().Bool("exclude-muted", false, "是否排除已设置免打扰的群聊(默认 false)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runChatSearchCommon(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlags(cmd, "nicks"); err != nil {
|
||||
return err
|
||||
@@ -1277,34 +1362,23 @@ func newChatCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
|
||||
chatSearchCmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: "根据关键词搜索群聊",
|
||||
Long: `根据关键词搜索群聊列表。分页参数 --limit(默认 20)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。
|
||||
|
||||
注意:
|
||||
1. query 不要拆分得太细,应使用群名称中连续的核心词作为关键词(如群名"项目冲刺群"应搜"项目冲刺"而非拆成"项目"+"冲刺"分别搜索)。
|
||||
2. 当搜索结果返回多个群聊时,应列出候选群让用户确认目标群聊,不要自行假定并直接进行后续操作。`,
|
||||
Example: ` dws chat search --query "项目冲刺"
|
||||
dws chat search --query "项目冲刺" --limit 20 --cursor 0`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
keyword := flagOrFallback(cmd, "query", "keyword")
|
||||
if keyword == "" {
|
||||
return fmt.Errorf("flag --query is required\n hint: dws chat search --query \"test\"")
|
||||
}
|
||||
limit := chatIntFlagOrFallback(cmd, "limit", "size")
|
||||
cursor, _ := cmd.Flags().GetString("cursor")
|
||||
toolArgs := map[string]any{
|
||||
"keyword": keyword,
|
||||
"limit": limit,
|
||||
"cursor": cursor,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("exclude-muted"); v {
|
||||
toolArgs["excludeMuted"] = true
|
||||
}
|
||||
return callMCPToolOnServer("im", "search_groups", toolArgs)
|
||||
chatSearchCmd := newChatGroupSearchCommand(false)
|
||||
chatGroupSearchCompatibilityCmd := newChatGroupSearchCommand(true)
|
||||
cli.AttachRuntimeSchema(
|
||||
chatGroupSearchCompatibilityCmd,
|
||||
"chat",
|
||||
"search_groups",
|
||||
"reviewed-compatibility:chat-group-search",
|
||||
)
|
||||
cli.AnnotateRuntimeCompatibilityEquivalence(
|
||||
chatSearchCmd,
|
||||
chatGroupSearchCompatibilityCmd,
|
||||
cli.RuntimeCompatibilityEquivalence{
|
||||
ID: "chat-group-search-compatibility-v1",
|
||||
Reason: "Both leaves share the same constructor, flags, positional normalization, read-only search_groups transport, and result contract.",
|
||||
Reviewed: true,
|
||||
},
|
||||
}
|
||||
)
|
||||
DeclareLeafMetadata(chatSearchCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
@@ -3353,15 +3427,6 @@ func newChatCommand() *cobra.Command {
|
||||
chatGroupCreateCmd.Flags().String("type", "INTERNAL", "群类型: INTERNAL(内部群,默认)/EXTERNAL(外部群)/NORMAL(普通群)")
|
||||
chatGroupCreateCmd.Flags().Bool("thread", false, "开启话题模式,将创建话题圈")
|
||||
|
||||
chatSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
|
||||
chatSearchCmd.Flags().String("keyword", "", "--query 的别名")
|
||||
_ = chatSearchCmd.Flags().MarkHidden("keyword")
|
||||
chatSearchCmd.Flags().Int("limit", 20, "每页返回数量(默认 20)")
|
||||
chatSearchCmd.Flags().Int("size", 0, "--limit 的旧版别名")
|
||||
_ = chatSearchCmd.Flags().MarkHidden("size")
|
||||
chatSearchCmd.Flags().String("cursor", "0", "分页游标(默认 \"0\",翻页传 nextCursor)")
|
||||
chatSearchCmd.Flags().Bool("exclude-muted", false, "是否排除已设置免打扰的群聊(默认 false)")
|
||||
|
||||
chatGroupMembersCmd.Flags().String("id", "", "群 ID / openconversation_id (必填)")
|
||||
_ = chatGroupMembersCmd.MarkFlagRequired("id")
|
||||
chatGroupMembersCmd.Flags().String("cursor", "", "分页游标,首次从 0 开始")
|
||||
@@ -3387,7 +3452,7 @@ func newChatCommand() *cobra.Command {
|
||||
_ = chatGroupMemberRemoveCmd.MarkFlagRequired("users")
|
||||
|
||||
chatGroupCmd.AddCommand(chatGroupCreateCmd, chatGroupMembersCmd, chatGroupRenameCmd)
|
||||
chatGroupCmd.AddCommand(hintSubCmd("search", "use: dws chat search --query <关键词>"))
|
||||
chatGroupCmd.AddCommand(chatGroupSearchCompatibilityCmd)
|
||||
chatGroupMembersCmd.AddCommand(chatGroupMemberAddCmd, chatGroupMemberRemoveCmd, chatGroupMembersAddBotCmd)
|
||||
|
||||
// message 子命令 flags
|
||||
@@ -3940,6 +4005,7 @@ func newChatCommand() *cobra.Command {
|
||||
chatCategoryDeleteCmd := &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "删除用户自定义会话分组",
|
||||
Long: "删除用户自定义会话分组。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。",
|
||||
Example: ` dws chat category delete --category-id <分组ID>
|
||||
# 分组ID 可通过 dws chat category list 获取`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -3947,6 +4013,14 @@ func newChatCommand() *cobra.Command {
|
||||
if categoryId == 0 {
|
||||
return fmt.Errorf("flag --category-id is required")
|
||||
}
|
||||
if !commandBoolFlag(cmd, "yes") {
|
||||
return apperrors.NewValidation(
|
||||
"删除会话分组不可逆;获得用户确认后加 --yes 执行",
|
||||
apperrors.WithReason("confirmation_required"),
|
||||
apperrors.WithHint("先确认目标分组及影响范围;用户明确同意后以相同参数追加 --yes"),
|
||||
apperrors.WithActions("确认目标会话分组", "获得用户确认后使用 --yes 执行"),
|
||||
)
|
||||
}
|
||||
return callMCPToolOnServer("im", "delete_conv_category", map[string]any{
|
||||
"categoryId": categoryId,
|
||||
})
|
||||
@@ -6261,8 +6335,12 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
if err := validateRequiredFlags(cmd, "group"); err != nil {
|
||||
return err
|
||||
}
|
||||
groupID, err := resolveNativeChatTarget(mustGetFlag(cmd, "group"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("bot", "list_group_bots", map[string]any{
|
||||
"openConversationId": mustGetFlag(cmd, "group"),
|
||||
"openConversationId": groupID,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -6296,7 +6374,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
},
|
||||
},
|
||||
})
|
||||
chatGroupBotsCmd.Flags().String("group", "", "群聊 openConversationId (必填)")
|
||||
chatGroupBotsCmd.Flags().String("group", "", "群聊 openConversationId 或需唯一解析的群名 (必填)")
|
||||
_ = chatGroupBotsCmd.MarkFlagRequired("group")
|
||||
|
||||
chatGroupMembersRemoveBotCmd := &cobra.Command{
|
||||
@@ -6432,7 +6510,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
Use: "dismiss",
|
||||
Short: "解散群聊",
|
||||
Long: `解散指定群聊。该操作不可逆,需要群主权限;必须先获得用户确认,再追加 --yes 执行。`,
|
||||
Example: ` dws chat group dismiss --group <openConversationId> --yes
|
||||
Example: ` dws chat group dismiss --group <openConversationId>
|
||||
# 查询群 ID: dws chat search --query "群名"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "group"); err != nil {
|
||||
@@ -7276,7 +7354,7 @@ status 可选值:
|
||||
chatClearMessagesCmd := &cobra.Command{
|
||||
Use: "clear-messages",
|
||||
Short: "清空当前用户指定会话的聊天记录",
|
||||
Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。
|
||||
Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。
|
||||
|
||||
如何获取 openConversationId(如果上层已有则直接使用,不必再查):
|
||||
- 群聊:dws chat search --query "群名"
|
||||
@@ -7288,6 +7366,14 @@ status 可选值:
|
||||
if convID == "" {
|
||||
return fmt.Errorf("flag --conversation-id is required\n hint: dws chat clear-messages --conversation-id <openConversationId>")
|
||||
}
|
||||
if !commandBoolFlag(cmd, "yes") {
|
||||
return apperrors.NewValidation(
|
||||
"清空会话聊天记录不可逆;获得用户确认后加 --yes 执行",
|
||||
apperrors.WithReason("confirmation_required"),
|
||||
apperrors.WithHint("先确认目标会话及影响范围;用户明确同意后以相同参数追加 --yes"),
|
||||
apperrors.WithActions("确认目标会话", "获得用户确认后使用 --yes 执行"),
|
||||
)
|
||||
}
|
||||
return callMCPToolOnServer("im", "clear_conversation_messages", map[string]any{
|
||||
"openConversationId": convID,
|
||||
})
|
||||
@@ -7763,8 +7849,8 @@ status 可选值:
|
||||
本命令升级已有普通群;新建外部群请使用 chat group create --type EXTERNAL。
|
||||
|
||||
该操作不可逆,仅群主可执行。正式执行必须通过 --yes 显式确认,可先使用 --dry-run 预览。`,
|
||||
Example: ` dws chat group upgrade-to-external --group <openConversationId> --yes
|
||||
dws chat group upgrade-to-external --group <openConversationId> --extension '{"source":"dws"}' --yes
|
||||
Example: ` dws chat group upgrade-to-external --group <openConversationId> --dry-run
|
||||
dws chat group upgrade-to-external --group <openConversationId> --extension '{"source":"dws"}' --dry-run
|
||||
# 查询群 ID: dws chat search --query "群名"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "group"); err != nil {
|
||||
@@ -8080,10 +8166,5 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
|
||||
|
||||
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd)
|
||||
|
||||
// hint: dws chat send → dws chat message send
|
||||
root.AddCommand(hintSubCmd("send", "use: dws chat message send"))
|
||||
// hint: dws chat history → dws chat message list
|
||||
root.AddCommand(hintSubCmd("history", "use: dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"))
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -43,6 +43,66 @@ func runChatCoverageDirect(t *testing.T, path []string, flags map[string]string)
|
||||
return command.RunE(command, nil)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEvaluationRegressionChatSearchSpellingsAndNaturalBotTarget(t *testing.T) {
|
||||
if got, err := resolveNativeChatTarget(" cid123456789 "); err != nil || got != "cid123456789" {
|
||||
t.Fatalf("stable native chat target = %q, %v", got, err)
|
||||
}
|
||||
t.Run("group search path accepts query", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
if err := runChatCoverageCommand(t, caller, "group", "search", "--query", "项目群"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("calls = %d", caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("group search accepts positional", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
if err := runChatCoverageCommand(t, caller, "group", "search", "项目群"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("calls = %d", caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("native bots resolves group name", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":[{"openConversationId":"cid-project","title":"项目群"}],"hasMore":false}`},
|
||||
{text: `{"result":{"bots":[]}}`},
|
||||
}}
|
||||
if err := runChatCoverageCommand(t, caller, "group", "bots", "--group", "项目群"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 2 {
|
||||
t.Fatalf("calls = %d", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMisroutedPathsRemainUnknownSubcommands(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
flag string
|
||||
}{
|
||||
{path: "send", flag: "--group"},
|
||||
{path: "history", flag: "--group"},
|
||||
} {
|
||||
caller := &productExampleCaller{}
|
||||
err := runChatCoverageCommand(t, caller, tc.path, tc.flag, "cid")
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown command") || !strings.Contains(err.Error(), tc.path) {
|
||||
t.Fatalf("chat %s error = %v, want unknown command", tc.path, err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "unknown flag") {
|
||||
t.Fatalf("chat %s was misreported as a flag error: %v", tc.path, err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("chat %s tool calls = %d, want 0", tc.path, caller.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupUpdateIconAcceptsUploadedMediaIDPrefixes(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
@@ -112,6 +172,7 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T)
|
||||
{"message", "search", "--query=q", "--start=2026-01-02T00:00:00Z", "--end=2026-01-01T00:00:00Z"},
|
||||
{"message", "search", "--query=q", "--start=2026-01-01T00:00:00Z", "--end=2026-01-02T00:00:00Z", "--group=cid"},
|
||||
{"message", "recall", "--conversation-id=cid", "--msg-id=mid"},
|
||||
{"category", "delete", "--category-id=1"},
|
||||
{"category", "rename", "--category-id=1", "--title=renamed"},
|
||||
{"category", "add-conv", "--group=cid", "--category-ids=1,2"},
|
||||
{"category", "remove-conv", "--group=cid", "--category-ids=1,2"},
|
||||
|
||||
@@ -64,6 +64,7 @@ func TestCrossPlatformCoverageReadToolNameContractAndHelperBoundary(t *testing.T
|
||||
"LIST_MESSAGES": true,
|
||||
"query_send_status": true,
|
||||
"search_messages": true,
|
||||
"enterprise_person_search": true,
|
||||
"unread_message_conversation_list": true,
|
||||
"send_personal_message": false,
|
||||
"": false,
|
||||
|
||||
@@ -190,6 +190,9 @@ func callMCPReadToolReturnTextOnServer(ctx context.Context, serverID, toolName s
|
||||
// ReadToolCaller.
|
||||
func IsReadToolName(toolName string) bool {
|
||||
toolName = strings.TrimSpace(strings.ToLower(toolName))
|
||||
if toolName == "enterprise_person_search" {
|
||||
return true
|
||||
}
|
||||
for _, prefix := range []string{
|
||||
"get_", "list_", "query_", "search_", "unread_",
|
||||
} {
|
||||
|
||||
@@ -271,6 +271,10 @@ func TestCrossPlatformCoverageProtectSheetMutationCommandPanics(t *testing.T) {
|
||||
func TestSheetMutationGuardRejectsPipedYesEvenWithContractConfirmSafety(t *testing.T) {
|
||||
// Sheet agent hardening: outer --yes-only gate must win over ConfirmSafety
|
||||
// honoring piped stdin yes (review: delete-sheet / range clear / version revert).
|
||||
// This fixture exercises the no-caller fallback. Isolate it from commands
|
||||
// built by earlier tests, which may initialize the package-level deps.
|
||||
testseam.Protect(t, &deps)
|
||||
deps = nil
|
||||
ran := false
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete-sheet",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
// Package profilectx owns the process-local profile selector without importing
|
||||
// authentication or transport packages.
|
||||
package profilectx
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
runtimeProfileMu sync.RWMutex
|
||||
runtimeProfile string
|
||||
)
|
||||
|
||||
// Set records the explicit profile selector for the current process.
|
||||
func Set(profile string) {
|
||||
runtimeProfileMu.Lock()
|
||||
defer runtimeProfileMu.Unlock()
|
||||
runtimeProfile = strings.TrimSpace(profile)
|
||||
}
|
||||
|
||||
// Get returns the explicit process-local profile selector.
|
||||
func Get() string {
|
||||
runtimeProfileMu.RLock()
|
||||
defer runtimeProfileMu.RUnlock()
|
||||
return runtimeProfile
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package profilectx
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossPlatformCoverageSetAndGet(t *testing.T) {
|
||||
t.Cleanup(func() { Set("") })
|
||||
|
||||
Set(" fixture-profile ")
|
||||
if got := Get(); got != "fixture-profile" {
|
||||
t.Fatalf("Get() = %q, want fixture-profile", got)
|
||||
}
|
||||
|
||||
Set("")
|
||||
if got := Get(); got != "" {
|
||||
t.Fatalf("Get() after reset = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,16 @@ func FromShortcut(s Shortcut) corecmd.Spec {
|
||||
if !safetySpecDeclared(safety) {
|
||||
safety = shortcutSafetySpec(s.risk())
|
||||
}
|
||||
declaredContract := s.Contract
|
||||
if !declaredContract.Empty() && len(s.Aliases) > 0 && len(declaredContract.Identity.Aliases) == 0 {
|
||||
declaredContract.Identity.Aliases = make([]string, 0, len(s.Aliases))
|
||||
for _, alias := range s.Aliases {
|
||||
declaredContract.Identity.Aliases = append(
|
||||
declaredContract.Identity.Aliases,
|
||||
s.Service+" "+strings.TrimSpace(alias),
|
||||
)
|
||||
}
|
||||
}
|
||||
return corecmd.Spec{
|
||||
Use: s.Command,
|
||||
Short: s.Description,
|
||||
@@ -47,11 +57,12 @@ func FromShortcut(s Shortcut) corecmd.Spec {
|
||||
Flags: fromShortcutFlags(s.Flags),
|
||||
Constraints: fromShortcutConstraints(s.Constraints),
|
||||
Safety: safety,
|
||||
Contract: s.Contract,
|
||||
Contract: declaredContract,
|
||||
// Preserve the shipped Shortcut Catalog provenance: Cobra remains the
|
||||
// source for type/default/usage, while command adds Required/Enum/rules.
|
||||
ParameterProjection: corecmd.ProjectCobraParameters,
|
||||
Validate: fromShortcutValidate(s),
|
||||
PostMount: fromShortcutPostMount(s),
|
||||
// Multi-step body: command stays backend-agnostic, so the shortcut's own
|
||||
// RuntimeContext — which owns CallMCPData/CallMCPWriteData/Output — is
|
||||
// built here from the Ctx's command.
|
||||
@@ -65,6 +76,40 @@ func FromShortcut(s Shortcut) corecmd.Spec {
|
||||
}
|
||||
}
|
||||
|
||||
func fromShortcutPostMount(s Shortcut) func(*cobra.Command) {
|
||||
if len(s.Aliases) == 0 && strings.TrimSpace(s.SinglePositionalAliasFor) == "" {
|
||||
return nil
|
||||
}
|
||||
return func(cmd *cobra.Command) {
|
||||
cmd.Aliases = append([]string(nil), s.Aliases...)
|
||||
name := strings.TrimSpace(s.SinglePositionalAliasFor)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
cmd.Args = func(cmd *cobra.Command, args []string) error {
|
||||
if err := cobra.MaximumNArgs(1)(cmd, args); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
flag := cmd.Flags().Lookup(name)
|
||||
if flag == nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf(
|
||||
"shortcut %s %s positional alias flag --%s is not registered",
|
||||
s.Service, s.Command, name))
|
||||
}
|
||||
if flag.Changed {
|
||||
return apperrors.NewValidation(fmt.Sprintf("位置参数与 --%s 不能同时提供", name))
|
||||
}
|
||||
if err := cmd.Flags().Set(name, args[0]); err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("位置参数无法写入 --%s: %v", name, err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func safetySpecDeclared(safety contract.SafetySpec) bool {
|
||||
return strings.TrimSpace(safety.Effect) != "" ||
|
||||
strings.TrimSpace(safety.Risk) != "" ||
|
||||
@@ -133,6 +178,7 @@ func fromShortcutFlags(flags []Flag) []corecmd.FlagSpec {
|
||||
for _, f := range flags {
|
||||
out = append(out, corecmd.FlagSpec{
|
||||
Name: f.Name,
|
||||
Shorthand: f.Shorthand,
|
||||
Usage: flagHelp(f),
|
||||
Kind: fromShortcutFlagKind(f.Type),
|
||||
Default: f.Default,
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
// TestCrossPlatformCoverageFromShortcutMapsSharedBase verifies FromShortcut
|
||||
@@ -33,7 +34,7 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) {
|
||||
Hidden: true,
|
||||
Tips: []string{"dws chat +demo --name a"},
|
||||
Flags: []Flag{
|
||||
{Name: "name", Type: FlagString, Desc: "名称", Required: true, Default: "d", Enum: []string{"a", "b"}, Hidden: true},
|
||||
{Name: "name", Shorthand: "n", Type: FlagString, Desc: "名称", Required: true, Default: "d", Enum: []string{"a", "b"}, Hidden: true},
|
||||
{Name: "count", Type: FlagInt, Desc: "数量"},
|
||||
{Name: "flag", Type: FlagBool, Desc: "开关"},
|
||||
{Name: "ids", Type: FlagStringSlice, Desc: "列表"},
|
||||
@@ -85,7 +86,7 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) {
|
||||
}
|
||||
}
|
||||
name := cs.Flags[0]
|
||||
if name.Name != "name" || !name.Required || name.Default != "d" ||
|
||||
if name.Name != "name" || name.Shorthand != "n" || !name.Required || name.Default != "d" ||
|
||||
!name.Hidden || name.ValidationMode != corecmd.ValidationShortcut ||
|
||||
name.RequiredError != "缺少必填参数 --name:名称" ||
|
||||
strings.Join(name.Enum, ",") != "a,b" {
|
||||
@@ -120,6 +121,72 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFromShortcutAliasesAndPositionalAlias(t *testing.T) {
|
||||
executed := ""
|
||||
s := Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+search",
|
||||
Aliases: []string{"+search-group"},
|
||||
SinglePositionalAliasFor: "query",
|
||||
Description: "搜索群",
|
||||
Intent: "按名称搜索群",
|
||||
Contract: corecmd.ContractDecl{
|
||||
Description: "按名称搜索群",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: "test composite",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "搜索群", UseWhen: []string{"按名称搜索群"}, AvoidWhen: []string{"不要用于成员搜索"}, Examples: []string{"dws chat +search --query demo"},
|
||||
},
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat", Name: "shortcut_search", CanonicalPath: "chat.shortcut_search", CLIPath: "chat +search", PrimaryCLIPath: "chat +search",
|
||||
},
|
||||
},
|
||||
Flags: []Flag{{Name: "query", Desc: "关键词", Required: true}},
|
||||
Execute: func(rt *RuntimeContext) error { executed = rt.Str("query"); return nil },
|
||||
}
|
||||
spec := FromShortcut(s)
|
||||
if got := spec.Contract.Identity.Aliases; len(got) != 1 || got[0] != "chat +search-group" {
|
||||
t.Fatalf("contract aliases = %#v", got)
|
||||
}
|
||||
cmd := mount(s)
|
||||
if !cmd.HasAlias("+search-group") {
|
||||
t.Fatalf("cobra aliases = %#v", cmd.Aliases)
|
||||
}
|
||||
cmd.SetArgs([]string{"项目群"})
|
||||
if err := cmd.Execute(); err != nil || executed != "项目群" {
|
||||
t.Fatalf("positional execute err=%v value=%q", err, executed)
|
||||
}
|
||||
|
||||
conflict := mount(s)
|
||||
conflict.SetArgs([]string{"项目群", "--query", "另一个群"})
|
||||
if err := conflict.Execute(); err == nil || !strings.Contains(err.Error(), "不能同时提供") {
|
||||
t.Fatalf("positional/flag conflict err = %v", err)
|
||||
}
|
||||
tooMany := mount(s)
|
||||
tooMany.SetArgs([]string{"one", "two"})
|
||||
if err := tooMany.Execute(); err == nil {
|
||||
t.Fatal("multiple positional aliases unexpectedly accepted")
|
||||
}
|
||||
|
||||
missing := mount(Shortcut{
|
||||
Service: "chat", Command: "+missing", Description: "missing",
|
||||
SinglePositionalAliasFor: "query", Execute: func(*RuntimeContext) error { return nil },
|
||||
})
|
||||
missing.SetArgs([]string{"value"})
|
||||
if err := missing.Execute(); err == nil || !strings.Contains(err.Error(), "is not registered") {
|
||||
t.Fatalf("missing positional flag err = %v", err)
|
||||
}
|
||||
invalid := mount(Shortcut{
|
||||
Service: "chat", Command: "+invalid", Description: "invalid",
|
||||
SinglePositionalAliasFor: "query", Flags: []Flag{{Name: "query", Type: FlagInt}}, Execute: func(*RuntimeContext) error { return nil },
|
||||
})
|
||||
invalid.SetArgs([]string{"not-an-int"})
|
||||
if err := invalid.Execute(); err == nil || !strings.Contains(err.Error(), "无法写入") {
|
||||
t.Fatalf("invalid positional value err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageFromShortcutMatchesMountSurface pins the live
|
||||
// adapter surface: flag set (names/types/usage) and rendered Long must agree.
|
||||
// This catches a double-rendered 参数约束 or lost flagHelp decoration.
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
@@ -28,14 +31,15 @@ func executeShortcutBatchWrite(rt *shortcut.RuntimeContext, product, tool string
|
||||
})
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"preview_kind": "plan",
|
||||
"tool": tool,
|
||||
"actionCount": len(actions),
|
||||
"failedCount": 0,
|
||||
"actions": actions,
|
||||
"requestedCount": len(items),
|
||||
"contractVersion": "im.batch-write.v1",
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"preview_kind": "plan",
|
||||
"tool": tool,
|
||||
"actionCount": len(actions),
|
||||
"failedCount": 0,
|
||||
"actions": actions,
|
||||
"requestedCount": len(items),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,13 +60,33 @@ func executeShortcutBatchWrite(rt *shortcut.RuntimeContext, product, tool string
|
||||
}
|
||||
succeeded = append(succeeded, entry)
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"ok": len(failures) == 0,
|
||||
"partial": len(succeeded) > 0 && len(failures) > 0,
|
||||
"requestedCount": len(items),
|
||||
"succeededCount": len(succeeded),
|
||||
"failedCount": len(failures),
|
||||
"succeeded": succeeded,
|
||||
"failures": failures,
|
||||
})
|
||||
result := map[string]any{
|
||||
"contractVersion": "im.batch-write.v1",
|
||||
"ok": len(failures) == 0,
|
||||
"partial": len(succeeded) > 0 && len(failures) > 0,
|
||||
"requestedCount": len(items),
|
||||
"succeededCount": len(succeeded),
|
||||
"failedCount": len(failures),
|
||||
"succeeded": succeeded,
|
||||
"failures": failures,
|
||||
}
|
||||
if err := rt.Output(result); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return apperrors.NewAPI(
|
||||
fmt.Sprintf("批量执行 %s 失败:%d/%d 个目标未完成", tool, len(failures), len(items)),
|
||||
apperrors.WithOperation(product+"/"+tool),
|
||||
apperrors.WithReason("batch_write_failed"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"requestedCount": len(items),
|
||||
"succeededCount": len(succeeded),
|
||||
"failedCount": len(failures),
|
||||
"partial": len(succeeded) > 0,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -228,8 +228,8 @@ func botFindProject(data map[string]any) []map[string]any {
|
||||
|
||||
// SearchCommonGroups searches groups shared with given people (search_common_groups, chat server).
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
shortcut.Register(withReviewedChatShortcutContracts(
|
||||
BotSearch,
|
||||
BotFind,
|
||||
)
|
||||
)...)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -88,8 +89,8 @@ var ConversationSetTop = shortcut.Shortcut{
|
||||
Intent: "当你想把一个或多个单聊/群聊置顶到会话列表顶部、或取消置顶时使用;支持 1-10 个 openConversationId,逐项执行并返回成功/失败 ledger,某一项失败不阻断其余项。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"},
|
||||
{Name: "conversation-ids", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId(最多 10 个)"},
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "单个会话 openConversationId;会话 ID 去重后必须为 1-10 个"},
|
||||
{Name: "conversation-ids", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId;会话 ID 去重后必须为 1-10 个"},
|
||||
{Name: "off", Type: shortcut.FlagBool, Desc: "取消置顶(不传则设置置顶)"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
@@ -164,7 +165,7 @@ var ConversationMuteAtAll = shortcut.Shortcut{
|
||||
Command: "+conversation-mute-at-all",
|
||||
Product: "im",
|
||||
Description: "关闭/开启 @所有人消息提醒",
|
||||
Intent: "当你在某个群里不想再被'@所有人'打扰、或想恢复该提醒时使用;会实际修改该会话的@所有人提醒开关,需传 openConversationId。",
|
||||
Intent: "当你已对某个会话开启消息免打扰,并希望额外关闭或恢复'@所有人'提醒时使用;这是免打扰的子开关,若尚未开启总免打扰,先执行 +conversation-mute,否则平台会返回 NotificationOffNotEnabled。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||||
@@ -185,7 +186,7 @@ var ConversationMuteRedEnvelope = shortcut.Shortcut{
|
||||
Command: "+conversation-mute-red-envelope",
|
||||
Product: "im",
|
||||
Description: "关闭/开启红包消息提醒",
|
||||
Intent: "当你想在某个会话里关闭或恢复红包消息提醒时使用;会实际修改该会话的红包提醒开关,需传 openConversationId。",
|
||||
Intent: "当你已对某个会话开启消息免打扰,并希望额外关闭或恢复红包提醒时使用;这是免打扰的子开关,若尚未开启总免打扰,或刚恢复过@所有人提醒,先执行 +conversation-mute,否则平台会返回 NotificationOffNotEnabled。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||||
@@ -284,8 +285,8 @@ var ConversationList = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+conversation-list",
|
||||
Product: "im",
|
||||
Description: "分页获取当前用户的全部会话列表(单聊+群聊)",
|
||||
Intent: "当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;只读分页返回,可用 --exclude-muted 排除已免打扰会话。",
|
||||
Description: "分页或一键全量获取当前用户的会话列表(单聊+群聊)",
|
||||
Intent: "当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;默认读取一页,明确要求全部时使用 --page-all,CLI 会按服务端每页上限自动翻页并公开完整性 ledger;可用 --exclude-muted 排除已免打扰会话。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
@@ -299,47 +300,143 @@ var ConversationList = shortcut.Shortcut{
|
||||
CLIPath: "chat +conversation-list",
|
||||
PrimaryCLIPath: "chat +conversation-list",
|
||||
},
|
||||
Description: "分页获取当前用户的全部会话列表(单聊+群聊)",
|
||||
Description: "分页或一键全量获取当前用户的会话列表(单聊+群聊)",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "分页获取当前用户的全部会话列表(单聊+群聊)",
|
||||
UseWhen: []string{"当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;只读分页返回,可用 --exclude-muted 排除已免打扰会话。"},
|
||||
AgentSummary: "分页或一键全量获取当前用户的会话列表(单聊+群聊)",
|
||||
UseWhen: []string{"当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;默认读取一页,明确要求全部时使用 --page-all,CLI 会按服务端每页上限自动翻页并公开完整性 ledger;可用 --exclude-muted 排除已免打扰会话。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws chat +conversation-list --limit 50"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页数量(1-100)"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页数量;--limit 必须在 1-100"},
|
||||
{Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标(首次不传或 0)"},
|
||||
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部分页;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws chat +conversation-list --limit 50`,
|
||||
`dws chat +conversation-list --page-all --limit 100`,
|
||||
},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if limit := rt.Int("limit"); limit < 1 || limit > 100 {
|
||||
return apperrors.NewValidation("--limit 必须在 1-100 之间;读取全部会话请使用 --page-all")
|
||||
}
|
||||
if !rt.Bool("page-all") && rt.Changed("page-limit") {
|
||||
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
|
||||
}
|
||||
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > 500 {
|
||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Tips: []string{`dws chat +conversation-list --limit 50`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{}
|
||||
if rt.Int("limit") > 0 {
|
||||
params["limit"] = rt.Int("limit")
|
||||
cursor := int64(rt.Int("cursor"))
|
||||
pageLimit := 1
|
||||
if rt.Bool("page-all") {
|
||||
pageLimit = rt.Int("page-limit")
|
||||
}
|
||||
if rt.Int("cursor") > 0 {
|
||||
params["cursor"] = rt.Int("cursor")
|
||||
convs := make([]map[string]any, 0)
|
||||
seenConversations := map[string]bool{}
|
||||
seenCursors := map[int64]bool{cursor: true}
|
||||
pagesFetched := 0
|
||||
complete := false
|
||||
hasMore := false
|
||||
nextCursor := int64(0)
|
||||
failures := make([]map[string]any, 0)
|
||||
for pagesFetched < pageLimit {
|
||||
params := map[string]any{"limit": rt.Int("limit")}
|
||||
if cursor > 0 {
|
||||
params["cursor"] = cursor
|
||||
}
|
||||
if rt.Bool("exclude-muted") {
|
||||
params["excludeMuted"] = true
|
||||
}
|
||||
data, err := rt.CallMCPData("im", "list_all_conversations", params)
|
||||
if err != nil {
|
||||
if pagesFetched == 0 {
|
||||
return err
|
||||
}
|
||||
failures = append(failures, map[string]any{"stage": "conversation-page", "cursor": cursor, "error": err.Error()})
|
||||
break
|
||||
}
|
||||
pagesFetched++
|
||||
for _, conversation := range conversationListProject(data) {
|
||||
id := strings.TrimSpace(fmt.Sprint(conversation["openConversationId"]))
|
||||
if id != "" && id != "<nil>" {
|
||||
if seenConversations[id] {
|
||||
continue
|
||||
}
|
||||
seenConversations[id] = true
|
||||
}
|
||||
convs = append(convs, conversation)
|
||||
}
|
||||
page := chatmsg.Pagination(data)
|
||||
hasMoreValue, known := page["hasMore"].(bool)
|
||||
hasMore = hasMoreValue
|
||||
if !known {
|
||||
failures = append(failures, map[string]any{"stage": "conversation-pagination", "error": "下层未返回 hasMore,无法证明结果完整"})
|
||||
break
|
||||
}
|
||||
if !hasMore {
|
||||
complete = true
|
||||
break
|
||||
}
|
||||
nextCursor, err = conversationPaginationCursor(page["nextCursor"])
|
||||
if err != nil || nextCursor == 0 || seenCursors[nextCursor] {
|
||||
failures = append(failures, map[string]any{"stage": "conversation-pagination", "error": "hasMore=true 但 nextCursor 缺失、无效或未前进"})
|
||||
break
|
||||
}
|
||||
if !rt.Bool("page-all") {
|
||||
break
|
||||
}
|
||||
seenCursors[nextCursor] = true
|
||||
cursor = nextCursor
|
||||
}
|
||||
if rt.Bool("exclude-muted") {
|
||||
params["excludeMuted"] = true
|
||||
if rt.Bool("page-all") && hasMore && pagesFetched == pageLimit {
|
||||
failures = append(failures, map[string]any{"stage": "conversation-page-limit", "error": fmt.Sprintf("达到 --page-limit=%d,仍有更多会话", pageLimit)})
|
||||
}
|
||||
data, err := rt.CallMCPData("im", "list_all_conversations", params)
|
||||
if err != nil {
|
||||
return err
|
||||
payload := map[string]any{
|
||||
"count": len(convs),
|
||||
"conversations": convs,
|
||||
"pagesFetched": pagesFetched,
|
||||
"complete": complete,
|
||||
"hasMore": hasMore,
|
||||
"nextCursor": nextCursor,
|
||||
"paginationKnown": len(failures) == 0 || hasMore,
|
||||
"failedCount": len(failures),
|
||||
"failures": failures,
|
||||
"partial": len(failures) > 0,
|
||||
}
|
||||
convs := conversationListProject(data)
|
||||
payload := map[string]any{"count": len(convs), "conversations": convs}
|
||||
chatmsg.ApplyPagination(payload, data)
|
||||
return rt.Output(payload)
|
||||
},
|
||||
}
|
||||
|
||||
func conversationPaginationCursor(value any) (int64, error) {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return int64(typed), nil
|
||||
case int64:
|
||||
return typed, nil
|
||||
case float64:
|
||||
return int64(typed), nil
|
||||
case string:
|
||||
return strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported cursor type %T", value)
|
||||
}
|
||||
}
|
||||
|
||||
// conversationListProject reshapes the raw list_all_conversations response into a
|
||||
// clean conversation list — clean output projection. Both the list
|
||||
// container and the per-item field names are probed defensively across candidate
|
||||
@@ -379,12 +476,12 @@ func conversationListResolveList(data map[string]any) []any {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
return unwrapConversationTuple(arr)
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"conversationList", "conversations", "list", "items", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
return unwrapConversationTuple(arr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -392,6 +489,19 @@ func conversationListResolveList(data map[string]any) []any {
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// unwrapConversationTuple handles gateway responses shaped as
|
||||
// result:[conversationList,nextCursor,hasMore] while leaving ordinary arrays
|
||||
// untouched. This prevents the first list from being mistaken for one row.
|
||||
func unwrapConversationTuple(values []any) []any {
|
||||
if len(values) == 0 {
|
||||
return values
|
||||
}
|
||||
if nested, ok := values[0].([]any); ok {
|
||||
return nested
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// conversationListFirst returns the first present candidate key's value.
|
||||
func conversationListFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
@@ -576,12 +686,12 @@ func conversationListTopResolveList(data map[string]any) []any {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
return unwrapConversationTuple(arr)
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"conversationList", "conversations", "topConversations", "list", "items", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
return unwrapConversationTuple(arr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1070,7 +1180,7 @@ var CategoryRemoveConversation = shortcut.Shortcut{
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
shortcut.Register(withReviewedChatShortcutContracts(
|
||||
ConversationInfo,
|
||||
ConversationSetTop,
|
||||
ConversationMute,
|
||||
@@ -1091,5 +1201,5 @@ func init() {
|
||||
CategoryRename,
|
||||
CategoryAddConversation,
|
||||
CategoryRemoveConversation,
|
||||
)
|
||||
)...)
|
||||
}
|
||||
|
||||
@@ -14,13 +14,15 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
)
|
||||
|
||||
func TestConversationListTopProjectNormalizesAndFiltersType(t *testing.T) {
|
||||
func TestCrossPlatformCoverageConversationListTopProjectNormalizesAndFiltersType(t *testing.T) {
|
||||
data := map[string]any{
|
||||
"result": map[string]any{
|
||||
"items": []any{
|
||||
@@ -75,7 +77,7 @@ func TestConversationListTopProjectNormalizesAndFiltersType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationListTopRejectsInvalidType(t *testing.T) {
|
||||
func TestCrossPlatformCoverageConversationListTopRejectsInvalidType(t *testing.T) {
|
||||
fake := &platformCoverageCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
@@ -87,3 +89,82 @@ func TestConversationListTopRejectsInvalidType(t *testing.T) {
|
||||
t.Fatalf("invalid --type reached lower tool %s/%s", fake.product, fake.tool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageConversationListProjectUnwrapsGatewayTuple(t *testing.T) {
|
||||
data := map[string]any{
|
||||
"result": []any{
|
||||
[]any{map[string]any{"openConversationId": "cid-1", "title": "项目群"}},
|
||||
float64(2),
|
||||
true,
|
||||
},
|
||||
}
|
||||
if got := conversationListProject(data); len(got) != 1 || got[0]["openConversationId"] != "cid-1" {
|
||||
t.Fatalf("conversation tuple projection = %#v", got)
|
||||
}
|
||||
if got := conversationListTopProject(data); len(got) != 1 || got[0]["openConversationId"] != "cid-1" {
|
||||
t.Fatalf("top tuple projection = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageConversationListPageAllFollowsTypedCursor(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
|
||||
"im/list_all_conversations": {
|
||||
`{"result":{"conversationList":[{"openConversationId":"cid-1","title":"一"}],"hasMore":true,"nextCursor":2}}`,
|
||||
`{"result":{"conversationList":[{"openConversationId":"cid-2","title":"二"}],"hasMore":false}}`,
|
||||
},
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+conversation-list", "--page-all"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 2 || fake.calls[1].args["cursor"] != int64(2) {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageConversationListSinglePagePreservesTypedCursor(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1","title":"一"}],"hasMore":true,"nextCursor":2}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+conversation-list"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("calls = %#v, want exactly one page", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["hasMore"] != true || payload["nextCursor"] != float64(2) {
|
||||
t.Fatalf("pagination payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageConversationListDeduplicatesStableIDs(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1","title":"一"},{"openConversationId":"cid-1","title":"重复"}],"hasMore":false}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+conversation-list"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(1) {
|
||||
t.Fatalf("deduplicated payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,16 +23,19 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
)
|
||||
|
||||
// ChatSearch searches groups by keyword (search_groups on the im server).
|
||||
var ChatSearch = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-search",
|
||||
Product: "im",
|
||||
Description: "按关键词搜索群聊",
|
||||
Intent: "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Service: "chat",
|
||||
Command: "+chat-search",
|
||||
Aliases: []string{"+chat-group-search", "+search-group"},
|
||||
SinglePositionalAliasFor: "query",
|
||||
Product: "im",
|
||||
Description: "按关键词搜索群聊",
|
||||
Intent: "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
@@ -147,7 +150,7 @@ var ChatInviteURL = shortcut.Shortcut{
|
||||
Command: "+chat-invite-url",
|
||||
Product: "im",
|
||||
Description: "获取群邀请链接",
|
||||
Intent: "当你想拿到一条群邀请链接分享给别人加群时使用;只读生成链接,需传群 openConversationId,可用 --expires-seconds 设置有效期(0 表示永久)。",
|
||||
Intent: "当你想拿到一条群邀请链接分享给别人加群时使用;--group 可传群 openConversationId 或群名,多命中会安全停止。可用 --expires-seconds 设置有效期(0 表示永久)。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
@@ -169,20 +172,32 @@ var ChatInviteURL = shortcut.Shortcut{
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "获取群邀请链接",
|
||||
UseWhen: []string{"当你想拿到一条群邀请链接分享给别人加群时使用;只读生成链接,需传群 openConversationId,可用 --expires-seconds 设置有效期(0 表示永久)。"},
|
||||
UseWhen: []string{"当你想拿到一条群邀请链接分享给别人加群时使用;--group 可传群 openConversationId 或群名,多命中会安全停止。可用 --expires-seconds 设置有效期(0 表示永久)。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws chat +chat-invite-url --group <openConversationId>"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId;兼容直接传群名并唯一解析"},
|
||||
{Name: "chat-query", Type: shortcut.FlagString, Desc: "--group 的旧版自然名称入口", Hidden: true},
|
||||
{Name: "group-query", Type: shortcut.FlagString, Desc: "--chat-query 的兼容别名", Hidden: true},
|
||||
{Name: "expires-seconds", Type: shortcut.FlagInt, Desc: "链接有效期(秒),0 表示永久"},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-invite-url --group <openConversationId>`},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"group", "chat-query", "group-query"}},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws chat +chat-invite-url --group <openConversationId>`,
|
||||
`dws chat +chat-invite-url --group "项目群"`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
groupID, err := resolveStableOrNamedChat(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"openConversationId": rt.Str("group"),
|
||||
"cid": rt.Str("group"),
|
||||
"openConversationId": groupID,
|
||||
"cid": groupID,
|
||||
}
|
||||
if rt.Changed("expires-seconds") {
|
||||
params["expiresSeconds"] = rt.Int("expires-seconds")
|
||||
@@ -777,7 +792,7 @@ var ChatBots = shortcut.Shortcut{
|
||||
Command: "+chat-bots",
|
||||
Product: "bot",
|
||||
Description: "查看群内所有机器人",
|
||||
Intent: "当你想查看某个群里已添加了哪些机器人时使用;需传群 openConversationId,只读返回群内机器人列表(含 openBotId,供后续移除)。",
|
||||
Intent: "当你想查看某个群里已添加了哪些机器人时使用;--group 可传群 openConversationId 或群名,多命中会安全停止。只读返回机器人列表(含 openBotId,供后续移除)。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
@@ -799,17 +814,29 @@ var ChatBots = shortcut.Shortcut{
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查看群内所有机器人",
|
||||
UseWhen: []string{"当你想查看某个群里已添加了哪些机器人时使用;需传群 openConversationId,只读返回群内机器人列表(含 openBotId,供后续移除)。"},
|
||||
UseWhen: []string{"当你想查看某个群里已添加了哪些机器人时使用;--group 可传群 openConversationId 或群名,多命中会安全停止。只读返回机器人列表(含 openBotId,供后续移除)。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws chat +chat-bots --group <openConversationId>"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId;兼容直接传群名并唯一解析"},
|
||||
{Name: "chat-query", Type: shortcut.FlagString, Desc: "--group 的旧版自然名称入口", Hidden: true},
|
||||
{Name: "group-query", Type: shortcut.FlagString, Desc: "--chat-query 的兼容别名", Hidden: true},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"group", "chat-query", "group-query"}},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws chat +chat-bots --group <openConversationId>`,
|
||||
`dws chat +chat-bots --group "项目群"`,
|
||||
},
|
||||
Tips: []string{`dws chat +chat-bots --group <openConversationId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("bot", "list_group_bots", map[string]any{"openConversationId": rt.Str("group")})
|
||||
groupID, err := resolveStableOrNamedChat(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rt.CallMCPData("bot", "list_group_bots", map[string]any{"openConversationId": groupID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -818,6 +845,21 @@ var ChatBots = shortcut.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// resolveStableOrNamedChat gives read-only group shortcuts one safe target
|
||||
// contract. Stable cid values bypass search; natural names always go through
|
||||
// the shared exact-match, full-pagination and ambiguity rules.
|
||||
func resolveStableOrNamedChat(rt *shortcut.RuntimeContext) (string, error) {
|
||||
resolved, err := targetresolver.ResolveChatTarget(
|
||||
rt,
|
||||
strings.TrimSpace(rt.Str("group")),
|
||||
strings.TrimSpace(rt.StrFirst("chat-query", "group-query")),
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolved.Selected.OpenConversationID, nil
|
||||
}
|
||||
|
||||
// chatBotsProject reshapes list_group_bots into a clean bot list
|
||||
// ({openBotId, name}) — clean output projection. List container and
|
||||
// per-item field names are probed defensively across candidate keys so shape
|
||||
@@ -1461,7 +1503,7 @@ var ChatRoleQueryUser = shortcut.Shortcut{
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
shortcut.Register(withReviewedChatShortcutContracts(
|
||||
ChatSearch,
|
||||
ChatMembersGet,
|
||||
ChatTransferOwner,
|
||||
@@ -1491,5 +1533,5 @@ func init() {
|
||||
ChatRoleSetUser,
|
||||
ChatRoleRemoveUser,
|
||||
ChatRoleQueryUser,
|
||||
)
|
||||
)...)
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ import (
|
||||
"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"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
)
|
||||
@@ -110,8 +110,8 @@ var MessagesSendByWebhook = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+messages-send-by-webhook",
|
||||
Product: "bot",
|
||||
Description: "自定义机器人 Webhook 发送群消息",
|
||||
Intent: "当你只有自定义机器人的 Webhook token、想往其所在群推送消息时使用;会实际通过 Webhook 发群消息,需传 token、标题、正文,可 @手机号/userId 或 @所有人。",
|
||||
Description: "兼容旧入口的自定义机器人 Webhook 群消息发送",
|
||||
Intent: "只有既有自动化明确依赖 +messages-send-by-webhook 兼容路径、暂时不能迁移统一身份入口时使用",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
@@ -125,15 +125,15 @@ var MessagesSendByWebhook = shortcut.Shortcut{
|
||||
CLIPath: "chat +messages-send-by-webhook",
|
||||
PrimaryCLIPath: "chat +messages-send-by-webhook",
|
||||
},
|
||||
Description: "自定义机器人 Webhook 发送群消息",
|
||||
Description: "兼容旧入口的自定义机器人 Webhook 群消息发送",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "自定义机器人 Webhook 发送群消息",
|
||||
UseWhen: []string{"当你只有自定义机器人的 Webhook token、想往其所在群推送消息时使用;会实际通过 Webhook 发群消息,需传 token、标题、正文,可 @手机号/userId 或 @所有人。"},
|
||||
AgentSummary: "兼容旧入口的自定义机器人 Webhook 群消息发送",
|
||||
UseWhen: []string{"只有既有自动化明确依赖 +messages-send-by-webhook 兼容路径、暂时不能迁移统一身份入口时使用"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws chat +messages-send-by-webhook --token <token> --title \"告警\" --text \"CPU 超 90%\" --at-all"},
|
||||
},
|
||||
@@ -172,17 +172,56 @@ var MessagesRecall = shortcut.Shortcut{
|
||||
Command: "+messages-recall",
|
||||
Product: "im",
|
||||
Description: "撤回当前用户发送的消息",
|
||||
Intent: "当你想撤回当前用户刚发出的某条消息时使用;会实际撤回消息,需传会话 openConversationId 和消息 openMessageId。",
|
||||
Intent: "当你想撤回当前用户刚发出的某条消息时使用;会实际撤回消息。推荐同时传会话 openConversationId 和消息 openMessageId;若只传一个消息 ID,CLI 会先只读查询消息详情并补齐会话 ID。兼容 --message-id/--message-ids 的单值写法。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||||
{Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true},
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId;省略时从消息详情解析"},
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
|
||||
{Name: "chat", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
|
||||
{Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId;一次只能撤回一个消息 ID;--message-ids 仅接受单值"},
|
||||
{Name: "message-id", Type: shortcut.FlagString, Desc: "--msg-id 的兼容别名", Hidden: true},
|
||||
{Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "--msg-id 的兼容单值别名;不支持批量撤回", Hidden: true},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"msg-id", "message-id", "message-ids"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"msg-id"}, Description: "一次只能撤回一个消息 ID;--message-ids 仅接受单值"},
|
||||
},
|
||||
Tips: []string{`dws chat +messages-recall --conversation-id <openConversationId> --msg-id <openMessageId>`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
messageIDs := uniqueShortcutStrings(append(
|
||||
[]string{rt.StrFirst("msg-id", "message-id")},
|
||||
rt.StrSlice("message-ids")...,
|
||||
))
|
||||
if len(messageIDs) != 1 {
|
||||
return apperrors.NewValidation("撤回一次只接受一个消息 ID;请通过 --msg-id 或单值 --message-ids 传入")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
messageIDs := uniqueShortcutStrings(append(
|
||||
[]string{rt.StrFirst("msg-id", "message-id")},
|
||||
rt.StrSlice("message-ids")...,
|
||||
))
|
||||
messageID := messageIDs[0]
|
||||
conversationID := strings.TrimSpace(rt.StrFirst("conversation-id", "group", "id", "chat"))
|
||||
if conversationID == "" {
|
||||
data, err := rt.CallMCPData("im", "list_messages_by_ids", map[string]any{"openMsgIds": []string{messageID}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messages := listMessagesResolveMaps(data)
|
||||
if len(messages) == 0 {
|
||||
return apperrors.NewValidation("无法根据消息 ID 查询到会话;请补充 --conversation-id")
|
||||
}
|
||||
conversationID = strings.TrimSpace(fmt.Sprint(chatmsg.ConversationID(messages[0])))
|
||||
if conversationID == "" || conversationID == "<nil>" {
|
||||
return apperrors.NewValidation("消息详情未返回会话 ID;请补充 --conversation-id")
|
||||
}
|
||||
}
|
||||
return rt.CallMCP("recall_message", map[string]any{
|
||||
"openConversationId": rt.Str("conversation-id"),
|
||||
"openMessageId": rt.Str("msg-id"),
|
||||
"openConversationId": conversationID,
|
||||
"openMessageId": messageID,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -314,46 +353,22 @@ func listMessageProjectOne(m map[string]any) map[string]any {
|
||||
}
|
||||
|
||||
func listMessageProjectOneWithReactions(m map[string]any, includeReactions bool) map[string]any {
|
||||
row := map[string]any{}
|
||||
if v, ok := listMessagesFirst(m, "openMessageId", "openMsgId", "messageId", "msgId"); ok {
|
||||
row["messageId"] = v
|
||||
}
|
||||
if v, ok := listMessagesFirst(m, "senderOpenDingTalkId", "senderUserId", "senderId", "senderStaffId"); ok {
|
||||
row["senderId"] = v
|
||||
}
|
||||
if v, ok := listMessagesFirst(m, "msgType", "messageType", "type"); ok {
|
||||
row["msgType"] = v
|
||||
}
|
||||
if v, ok := listMessagesFirst(m, "createTime", "sendTime", "gmtCreate", "messageTime"); ok {
|
||||
row["createTime"] = v
|
||||
}
|
||||
if text := chatmsg.Text(m); text != nil {
|
||||
row["text"] = text
|
||||
}
|
||||
if conversationID := chatmsg.ConversationID(m); conversationID != nil {
|
||||
row["conversationId"] = conversationID
|
||||
}
|
||||
if threadID := chatmsg.ThreadID(m); threadID != nil {
|
||||
row["threadId"] = threadID
|
||||
}
|
||||
if updateTime := chatmsg.UpdateTime(m); updateTime != nil {
|
||||
row["updateTime"] = updateTime
|
||||
}
|
||||
if includeReactions {
|
||||
if reactions := chatmsg.Reactions(m); len(reactions) > 0 {
|
||||
row["reactions"] = reactions
|
||||
row := chatmsg.ProjectMessageV1(m, includeReactions)
|
||||
// The established mget/list projection omits absent scalar fields; keep
|
||||
// that wire behavior even though the shared chat/search view retains them.
|
||||
for _, key := range []string{"sender", "text", "createTime"} {
|
||||
if row[key] == nil {
|
||||
delete(row, key)
|
||||
}
|
||||
}
|
||||
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
|
||||
row["quotedMessage"] = quoted
|
||||
// Keep the historical mget/list msgType alias while adding the canonical
|
||||
// messageType field from MessageViewV1.
|
||||
if messageType := chatmsg.MessageType(m); messageType != nil {
|
||||
row["msgType"] = messageType
|
||||
}
|
||||
if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
|
||||
row["resourceRefs"] = resources
|
||||
}
|
||||
projectForwarded := func(item map[string]any) map[string]any {
|
||||
if forwarded := chatmsg.Forwarded(m, func(item map[string]any) map[string]any {
|
||||
return listMessageProjectOneWithReactions(item, includeReactions)
|
||||
}
|
||||
if forwarded := chatmsg.Forwarded(m, projectForwarded); len(forwarded) > 0 {
|
||||
}); len(forwarded) > 0 {
|
||||
row["forwarded"] = forwarded
|
||||
}
|
||||
return row
|
||||
@@ -665,20 +680,35 @@ var MessagesMget = shortcut.Shortcut{
|
||||
}
|
||||
}
|
||||
notFound := make([]string, 0)
|
||||
failures := make([]map[string]any, 0)
|
||||
for _, id := range ids {
|
||||
if !found[id] {
|
||||
notFound = append(notFound, id)
|
||||
failures = append(failures, map[string]any{
|
||||
"stage": "mget",
|
||||
"messageId": id,
|
||||
"error": "下层未返回该消息",
|
||||
})
|
||||
}
|
||||
}
|
||||
payload := map[string]any{
|
||||
"contractVersion": chatmsg.MessageListContractVersion,
|
||||
"requestedCount": len(ids),
|
||||
"foundCount": len(ids) - len(notFound),
|
||||
"notFoundCount": len(notFound),
|
||||
"notFoundMessageIds": notFound,
|
||||
"messages": messages,
|
||||
"complete": len(notFound) == 0,
|
||||
"hasMore": false,
|
||||
"nextCursor": "",
|
||||
"paginationKnown": true,
|
||||
"pagesFetched": 1,
|
||||
"enrichedCount": 0,
|
||||
"failedCount": len(failures),
|
||||
"failures": failures,
|
||||
}
|
||||
if rt.Bool("download-resources") {
|
||||
payload["resourceDownloads"] = DownloadMessageResources(rt, rawMessages, "")
|
||||
AttachMessageResourceDownloads(payload, DownloadMessageResources(rt, rawMessages, ""))
|
||||
}
|
||||
return rt.Output(payload)
|
||||
},
|
||||
@@ -690,7 +720,7 @@ func MessageResourceDownloadFlags() []shortcut.Flag {
|
||||
return []shortcut.Flag{
|
||||
{Name: "download-resources", Type: shortcut.FlagBool, Desc: "自动下载消息中的全部可识别 mediaId/fileId 资源"},
|
||||
{Name: "output-dir", Type: shortcut.FlagString, Default: "./downloads", Desc: "资源输出目录;必须是工作目录内的相对路径,禁止绝对路径和 .. 逃逸"},
|
||||
{Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖同名资源文件(默认拒绝)"},
|
||||
{Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖工作目录内已存在的本地输出文件(默认拒绝)"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -902,6 +932,47 @@ func DownloadMessageResources(
|
||||
}
|
||||
}
|
||||
|
||||
// AttachMessageResourceDownloads publishes the download ledger and folds any
|
||||
// resource failure into the task-level completeness contract without dropping
|
||||
// successfully read messages or downloaded files.
|
||||
func AttachMessageResourceDownloads(payload, ledger map[string]any) {
|
||||
payload["resourceDownloads"] = ledger
|
||||
failed := messageLedgerInt(ledger["failedCount"])
|
||||
if failed == 0 {
|
||||
return
|
||||
}
|
||||
payload["complete"] = false
|
||||
payload["failedCount"] = messageLedgerInt(payload["failedCount"]) + failed
|
||||
taskFailures, _ := payload["failures"].([]map[string]any)
|
||||
resourceFailures, _ := ledger["failures"].([]map[string]any)
|
||||
for _, failure := range resourceFailures {
|
||||
row := make(map[string]any, len(failure)+1)
|
||||
row["stage"] = "resource-download"
|
||||
for key, value := range failure {
|
||||
row[key] = value
|
||||
}
|
||||
taskFailures = append(taskFailures, row)
|
||||
}
|
||||
payload["failures"] = taskFailures
|
||||
}
|
||||
|
||||
func messageLedgerInt(value any) int {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed
|
||||
case int32:
|
||||
return int(typed)
|
||||
case int64:
|
||||
return int(typed)
|
||||
case float32:
|
||||
return int(typed)
|
||||
case float64:
|
||||
return int(typed)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func disambiguateResourceDownloadFilename(filename string, used map[string]bool) string {
|
||||
if !used[strings.ToLower(filename)] {
|
||||
return filename
|
||||
@@ -1168,7 +1239,7 @@ var MessagesSendCard = shortcut.Shortcut{
|
||||
Command: "+messages-send-card",
|
||||
Product: "im",
|
||||
Description: "创建流式卡片,可在同一次调用中写入内容并结束",
|
||||
Intent: "当你要发送一张流式卡片消息时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成,避免卡片停留在加载中。",
|
||||
Intent: "当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
@@ -1190,7 +1261,7 @@ var MessagesSendCard = shortcut.Shortcut{
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "创建流式卡片,可在同一次调用中写入内容并结束",
|
||||
UseWhen: []string{"当你要发送一张流式卡片消息时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成,避免卡片停留在加载中。"},
|
||||
UseWhen: []string{"当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。"},
|
||||
AvoidWhen: []string{"已有 bizId、只需要追加或更新现有卡片内容时使用 +messages-update-card"},
|
||||
Examples: []string{
|
||||
"dws chat +messages-send-card --group <openConversationId> --content \"任务已完成\"",
|
||||
@@ -1221,7 +1292,7 @@ var MessagesSendCard = shortcut.Shortcut{
|
||||
`dws chat +messages-send-card --group <openConversationId> --content "任务已完成"`,
|
||||
},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if status := rt.Int("flow-status"); status < 1 || status > 5 {
|
||||
if status := rt.Int("flow-status"); !validCardFlowStatus(status) {
|
||||
return fmt.Errorf("--flow-status 必须在 1-5 之间")
|
||||
}
|
||||
if rt.Changed("flow-status") && rt.Str("content") == "" {
|
||||
@@ -1253,11 +1324,12 @@ var MessagesSendCard = shortcut.Shortcut{
|
||||
status := rt.Int("flow-status")
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"preview_kind": "plan",
|
||||
"actionCount": 2,
|
||||
"failedCount": 0,
|
||||
"contractVersion": currentCardWorkflowContract.Version,
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"preview_kind": "plan",
|
||||
"actionCount": 2,
|
||||
"failedCount": 0,
|
||||
"actions": []map[string]any{
|
||||
{
|
||||
"tool": "create_and_send_card",
|
||||
@@ -1291,11 +1363,12 @@ var MessagesSendCard = shortcut.Shortcut{
|
||||
return fmt.Errorf("卡片已创建(bizId=%s),但自动更新失败: %w", bizID, err)
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"ok": true,
|
||||
"bizId": bizID,
|
||||
"flowStatus": status,
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"contractVersion": currentCardWorkflowContract.Version,
|
||||
"ok": true,
|
||||
"bizId": bizID,
|
||||
"flowStatus": status,
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -1362,7 +1435,7 @@ var MessagesUpdateCard = shortcut.Shortcut{
|
||||
Command: "+messages-update-card",
|
||||
Product: "im",
|
||||
Description: "流式更新卡片内容(最后一次 --flow-status 应为 3)",
|
||||
Intent: "当你要向已发送的流式卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus(最后一次应为 3 表示完成)。",
|
||||
Intent: "当你要向已发送的流式文本卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus 1-5(最后一次应为 3 表示完成)。当前不支持 Card JSON 组件或 action callback。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
@@ -1384,7 +1457,7 @@ var MessagesUpdateCard = shortcut.Shortcut{
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "流式更新卡片内容(最后一次 --flow-status 应为 3)",
|
||||
UseWhen: []string{"当你要向已发送的流式卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus(最后一次应为 3 表示完成)。"},
|
||||
UseWhen: []string{"当你要向已发送的流式文本卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus 1-5(最后一次应为 3 表示完成)。当前不支持 Card JSON 组件或 action callback。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws chat +messages-update-card --biz-id <bizId> --content \"内容\" --flow-status 3"},
|
||||
},
|
||||
@@ -1392,9 +1465,18 @@ var MessagesUpdateCard = shortcut.Shortcut{
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "biz-id", Type: shortcut.FlagString, Desc: "send-card 返回的卡片业务 ID", Required: true},
|
||||
{Name: "content", Type: shortcut.FlagString, Desc: "卡片消息内容", Required: true},
|
||||
{Name: "flow-status", Type: shortcut.FlagInt, Desc: "流式状态 1处理中/2输入中/3完成/4执行中/5错误", Required: true},
|
||||
{Name: "flow-status", Type: shortcut.FlagInt, Desc: "流式状态 1处理中/2输入中/3完成/4执行中/5错误;--flow-status 必须在 1-5 之间", Required: true},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"flow-status"}, Description: "--flow-status 必须在 1-5 之间"},
|
||||
},
|
||||
Tips: []string{`dws chat +messages-update-card --biz-id <bizId> --content "内容" --flow-status 3`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if !validCardFlowStatus(rt.Int("flow-status")) {
|
||||
return fmt.Errorf("--flow-status 必须在 1-5 之间")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("update_streaming_card", map[string]any{
|
||||
"bizId": rt.Str("biz-id"),
|
||||
@@ -1735,7 +1817,7 @@ var MessagesUnsetTop = shortcut.Shortcut{
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
shortcut.Register(withReviewedChatShortcutContracts(
|
||||
MessagesSendByBot,
|
||||
MessagesBatchSendByBot,
|
||||
MessagesSendByWebhook,
|
||||
@@ -1764,5 +1846,5 @@ func init() {
|
||||
MessagesListPin,
|
||||
MessagesSetTop,
|
||||
MessagesUnsetTop,
|
||||
)
|
||||
)...)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
|
||||
const testCipher = "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP||2||1||1"
|
||||
|
||||
func TestListMessageProjectOne(t *testing.T) {
|
||||
func TestCrossPlatformCoverageListMessageProjectOne(t *testing.T) {
|
||||
// full field mapping + forwarded expansion; an encrypted body is marked (no
|
||||
// cross-conversation recovery), not leaked as base64.
|
||||
row := listMessageProjectOne(map[string]any{
|
||||
@@ -75,7 +75,35 @@ func TestListMessageProjectOne(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPinProjectPreservesThreadIdentity(t *testing.T) {
|
||||
func TestCrossPlatformCoverageAttachMessageResourceDownloadsPreservesMessagesAndMarksIncomplete(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"messages": []map[string]any{{"messageId": "msg-1"}},
|
||||
"complete": true,
|
||||
"failedCount": 0,
|
||||
"failures": []map[string]any{},
|
||||
}
|
||||
ledger := map[string]any{
|
||||
"failedCount": 1,
|
||||
"failures": []map[string]any{{
|
||||
"messageId": "msg-1",
|
||||
"error": "download failed",
|
||||
}},
|
||||
}
|
||||
AttachMessageResourceDownloads(payload, ledger)
|
||||
if payload["complete"] != false || payload["failedCount"] != 1 {
|
||||
t.Fatalf("task completeness = %#v", payload)
|
||||
}
|
||||
messages, _ := payload["messages"].([]map[string]any)
|
||||
if len(messages) != 1 || messages[0]["messageId"] != "msg-1" {
|
||||
t.Fatalf("messages were dropped: %#v", messages)
|
||||
}
|
||||
failures, _ := payload["failures"].([]map[string]any)
|
||||
if len(failures) != 1 || failures[0]["stage"] != "resource-download" {
|
||||
t.Fatalf("resource failures = %#v", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListPinProjectPreservesThreadIdentity(t *testing.T) {
|
||||
got := listPinProject(map[string]any{
|
||||
"result": map[string]any{
|
||||
"messages": []any{
|
||||
|
||||
@@ -94,6 +94,13 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
|
||||
wantTool: "search_groups",
|
||||
wantArgs: map[string]any{"keyword": "树莓派", "limit": 5},
|
||||
},
|
||||
{
|
||||
name: "chat search command alias and positional query",
|
||||
argv: []string{"chat", "+search-group", "树莓派", "--yes"},
|
||||
wantProduct: "im",
|
||||
wantTool: "search_groups",
|
||||
wantArgs: map[string]any{"keyword": "树莓派", "limit": 20},
|
||||
},
|
||||
{
|
||||
name: "bot find keyword",
|
||||
argv: []string{"chat", "+bot-find", "--keyword", "日报", "--yes"},
|
||||
@@ -175,6 +182,16 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
|
||||
"openConversationId": "cid-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "message recall plural single id alias",
|
||||
argv: []string{"chat", "+messages-recall", "--conversation-id", "cid-1", "--message-ids", "msg-1", "--yes"},
|
||||
wantProduct: "im",
|
||||
wantTool: "recall_message",
|
||||
wantArgs: map[string]any{
|
||||
"openConversationId": "cid-1",
|
||||
"openMessageId": "msg-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
@@ -203,6 +220,15 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageConversationNotificationSubSwitchesPublishMutePrerequisite(t *testing.T) {
|
||||
for _, command := range []shortcut.Shortcut{ConversationMuteAtAll, ConversationMuteRedEnvelope} {
|
||||
if !strings.Contains(command.Intent, "+conversation-mute") ||
|
||||
!strings.Contains(command.Intent, "NotificationOffNotEnabled") {
|
||||
t.Errorf("%s intent does not publish the live platform prerequisite: %q", command.Command, command.Intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) {
|
||||
t.Run("recognize open DingTalk IDs", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -248,7 +274,7 @@ func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatMuteMemberResolvesUserIDToOpenDingTalkID(t *testing.T) {
|
||||
func TestCrossPlatformCoverageChatMuteMemberResolvesUserIDToOpenDingTalkID(t *testing.T) {
|
||||
fake := &muteMemberResolutionCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
@@ -277,7 +303,7 @@ func TestChatMuteMemberResolvesUserIDToOpenDingTalkID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationCategoryTitleValidation(t *testing.T) {
|
||||
func TestCrossPlatformCoverageConversationCategoryTitleValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
argv []string
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -91,6 +93,62 @@ func TestCrossPlatformCoverageConversationAndGroupListExecution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageConversationListFailureBoundaries(t *testing.T) {
|
||||
for _, tail := range [][]string{
|
||||
{"--limit", "0"},
|
||||
{"--limit", "101"},
|
||||
{"--page-limit", "2"},
|
||||
{"--page-all", "--page-limit", "0"},
|
||||
{"--page-all", "--page-limit", "501"},
|
||||
} {
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(append([]string{"chat", "+conversation-list"}, tail...))
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Errorf("invalid conversation args succeeded: %v", tail)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
caller *larkAlignmentCaller
|
||||
args []string
|
||||
wantError bool
|
||||
}{
|
||||
{name: "first read failure", caller: &larkAlignmentCaller{failProductTool: "im/list_all_conversations"}, wantError: true},
|
||||
{name: "later read failure", caller: &larkAlignmentCaller{
|
||||
sequenceResponses: map[string][]string{"im/list_all_conversations": {`{"result":{"conversationList":[],"hasMore":true,"nextCursor":2}}`}},
|
||||
failProductToolAt: map[string]int{"im/list_all_conversations": 2},
|
||||
}, args: []string{"--page-all"}},
|
||||
{name: "missing pagination", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_all_conversations": `{"result":{"conversationList":[]}}`}}},
|
||||
{name: "invalid cursor", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_all_conversations": `{"result":{"conversationList":[],"hasMore":true,"nextCursor":"bad"}}`}}},
|
||||
{name: "stalled cursor", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_all_conversations": `{"result":{"conversationList":[],"hasMore":true,"nextCursor":2}}`}}, args: []string{"--page-all", "--cursor", "2"}},
|
||||
{name: "page limit", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_all_conversations": `{"result":{"conversationList":[],"hasMore":true,"nextCursor":2}}`}}, args: []string{"--page-all", "--page-limit", "1"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
helpers.InitDeps(tc.caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(append([]string{"chat", "+conversation-list"}, tc.args...))
|
||||
err := root.Execute()
|
||||
if (err != nil) != tc.wantError {
|
||||
t.Fatalf("error = %v, wantError=%v", err, tc.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for value, want := range map[any]int64{int(1): 1, int64(2): 2, float64(3): 3, "4": 4} {
|
||||
got, err := conversationPaginationCursor(value)
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("cursor %#v = %d, %v; want %d", value, got, err, want)
|
||||
}
|
||||
}
|
||||
if _, err := conversationPaginationCursor(struct{}{}); err == nil {
|
||||
t.Fatal("unsupported cursor unexpectedly accepted")
|
||||
}
|
||||
if got := unwrapConversationTuple(nil); got != nil {
|
||||
t.Fatalf("empty tuple = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -98,6 +156,12 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
|
||||
args []string
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "owner query resolution",
|
||||
caller: &larkAlignmentCaller{failProductTool: "contact/search_contact_by_key_word"},
|
||||
args: []string{"chat", "+chat-create", "--name", "群", "--users", "u1", "--owner-query", "群主", "--yes"},
|
||||
wantError: "fixture lower call failed",
|
||||
},
|
||||
{
|
||||
name: "current profile call",
|
||||
caller: &larkAlignmentCaller{failProductTool: "contact/get_current_user_profile"},
|
||||
@@ -152,6 +216,18 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
|
||||
args: []string{"chat", "+feed-group-query-item", "--category-id", "1", "--conversation-ids", "cid"},
|
||||
wantError: "fixture lower call failed",
|
||||
},
|
||||
{
|
||||
name: "chat update resolution",
|
||||
caller: &larkAlignmentCaller{responses: map[string]string{"im/search_groups": `{"result":[]}`}},
|
||||
args: []string{"chat", "+chat-update", "--group", "missing", "--name", "新群", "--yes"},
|
||||
wantError: "没有找到",
|
||||
},
|
||||
{
|
||||
name: "reply write",
|
||||
caller: &larkAlignmentCaller{failProductTool: "chat/send_personal_message"},
|
||||
args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", "D-sender", "--text", "收到", "--yes"},
|
||||
wantError: "fixture lower call failed",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -177,6 +253,27 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
|
||||
dry.calls[1].tool != "create_group_conversation" {
|
||||
t.Fatalf("chat-create dry-run calls = %#v", dry.calls)
|
||||
}
|
||||
|
||||
external := &larkAlignmentCaller{responses: map[string]string{
|
||||
"contact/search_contact_by_key_word": `{"result":[{"name":"外部联系人","openDingTalkId":"D-external"}]}`,
|
||||
}}
|
||||
helpers.InitDeps(external)
|
||||
root = newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+chat-create", "--name", "群", "--owner-open-dingtalk-id", "D-owner", "--member-query", "外部联系人", "--yes"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNaturalGroupReadFailures(t *testing.T) {
|
||||
for _, command := range []string{"+chat-invite-url", "+chat-bots"} {
|
||||
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{"im/search_groups": `{"result":[]}`}})
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", command, "--group", "missing"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Errorf("%s missing natural group unexpectedly resolved", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReplyShapeHelpers(t *testing.T) {
|
||||
@@ -240,6 +337,45 @@ func TestCrossPlatformCoverageFlagAndMgetValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecallCardAndLedgerBoundaries(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
caller *larkAlignmentCaller
|
||||
args []string
|
||||
}{
|
||||
{name: "multiple recall ids", caller: &larkAlignmentCaller{}, args: []string{"chat", "+messages-recall", "--message-ids", "m1,m2", "--yes"}},
|
||||
{name: "recall lookup failure", caller: &larkAlignmentCaller{failProductTool: "im/list_messages_by_ids"}, args: []string{"chat", "+messages-recall", "--msg-id", "m1", "--yes"}},
|
||||
{name: "recall lookup empty", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_messages_by_ids": `{"result":[]}`}}, args: []string{"chat", "+messages-recall", "--msg-id", "m1", "--yes"}},
|
||||
{name: "recall lookup missing conversation", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_messages_by_ids": `{"result":[{"openMessageId":"m1"}]}`}}, args: []string{"chat", "+messages-recall", "--msg-id", "m1", "--yes"}},
|
||||
{name: "card status low", caller: &larkAlignmentCaller{}, args: []string{"chat", "+messages-update-card", "--biz-id", "b", "--content", "x", "--flow-status", "0", "--yes"}},
|
||||
{name: "card status high", caller: &larkAlignmentCaller{}, args: []string{"chat", "+messages-update-card", "--biz-id", "b", "--content", "x", "--flow-status", "6", "--yes"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
helpers.InitDeps(tc.caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(tc.args)
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("failure boundary unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
helpers.InitDeps(&larkAlignmentCaller{})
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+messages-update-card", "--biz-id", "b", "--content", "x", "--flow-status", "3", "--yes"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for value, want := range map[any]int{
|
||||
int(1): 1, int32(2): 2, int64(3): 3, float32(4): 4, float64(5): 5, "bad": 0,
|
||||
} {
|
||||
if got := messageLedgerInt(value); got != want {
|
||||
t.Fatalf("messageLedgerInt(%#v) = %d, want %d", value, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFeedCompleteAndExcludeMuted(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{category: `{"result":{"hasMore":false,"list":[{"openConversationId":"cid"}]}}`}
|
||||
helpers.InitDeps(fake)
|
||||
@@ -388,6 +524,90 @@ func TestCrossPlatformCoverageUnifiedSendUnsupportedIdentityGuard(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUnifiedSendGroupFileAndBatchBoundaries(t *testing.T) {
|
||||
readGroupFile := messagesSendReadGroupFile
|
||||
t.Cleanup(func() { messagesSendReadGroupFile = readGroupFile })
|
||||
|
||||
for _, tail := range [][]string{
|
||||
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--groups", "c1", "--text", "x"},
|
||||
{"--identity", "bot", "--robot-code", "r", "--groups", "c1", "--groups-file", "groups.txt", "--text", "x"},
|
||||
{"--identity", "webhook", "--webhook-token", "token", "--chat-query", "群", "--text", "x"},
|
||||
{"--identity", "bot", "--robot-code", "r", "--groups", "", "--text", "x"},
|
||||
{"--identity", "bot", "--robot-code", "r", "--groups", strings.Join(makeIDs(101), ","), "--text", "x"},
|
||||
{"--identity", "bot", "--robot-code", "r", "--groups-file", "/absolute.txt", "--text", "x"},
|
||||
{"--identity", "bot", "--robot-code", "r", "--groups-file", "../escape.txt", "--text", "x"},
|
||||
{"--identity", "bot", "--robot-code", "r", "--groups-file", "missing.txt", "--text", "x"},
|
||||
} {
|
||||
helpers.InitDeps(&larkAlignmentCaller{})
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(append([]string{"chat", "+messages-send"}, append(tail, "--yes")...))
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Errorf("invalid unified send args succeeded: %v", tail)
|
||||
}
|
||||
}
|
||||
|
||||
temp := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(temp, "groups-dir"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(temp, "large.txt"), make([]byte, messagesSendMaxGroupFileSize+1), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(temp, "unreadable.txt"), []byte("cid"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
messagesSendReadGroupFile = func(path string) ([]byte, error) {
|
||||
if filepath.Base(path) == "unreadable.txt" {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
return readGroupFile(path)
|
||||
}
|
||||
t.Chdir(temp)
|
||||
for _, path := range []string{"groups-dir", "large.txt", "unreadable.txt"} {
|
||||
helpers.InitDeps(&larkAlignmentCaller{})
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+messages-send", "--identity", "bot", "--robot-code", "r", "--groups-file", path, "--text", "x", "--yes"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Errorf("invalid group file %q succeeded", path)
|
||||
}
|
||||
}
|
||||
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send", "--identity", "bot", "--robot-code", "r",
|
||||
"--groups", "c1,c2", "--text", "x", "--at-user-ids", "u1",
|
||||
"--at-open-dingtalk-ids", "D1", "--at-all", "--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 2 {
|
||||
t.Fatalf("batch calls = %#v", fake.calls)
|
||||
}
|
||||
for _, call := range fake.calls {
|
||||
if !reflect.DeepEqual(call.args["atUserIds"], []string{"u1"}) ||
|
||||
!reflect.DeepEqual(call.args["atOpendingtalkIds"], []string{"D1"}) ||
|
||||
call.args["isAtAll"] != "true" {
|
||||
t.Fatalf("batch mention args = %#v", call.args)
|
||||
}
|
||||
}
|
||||
|
||||
shortcut.Register(shortcut.Shortcut{
|
||||
Service: "chat", Command: "+coverage-unified-execute-error",
|
||||
Flags: MessagesSend.Flags, Execute: executeMessagesSend,
|
||||
})
|
||||
root = newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+coverage-unified-execute-error", "--identity", "bot", "--robot-code", "r",
|
||||
"--groups-file", "missing-again.txt", "--text", "x",
|
||||
})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("execute-time group file error was swallowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMuteMemberResolutionFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package chat
|
||||
|
||||
import "os"
|
||||
|
||||
var replaceFileAtomically = os.Rename
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
//go:build windows
|
||||
|
||||
package chat
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
var replaceFileAtomically = windows.Rename
|
||||
@@ -17,13 +17,22 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestMessagesSendPublishesCompleteIdentityConstraintInputs(t *testing.T) {
|
||||
type chatOutputErrorWriter struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (w chatOutputErrorWriter) Write([]byte) (int, error) {
|
||||
return 0, w.err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendPublishesCompleteIdentityConstraintInputs(t *testing.T) {
|
||||
want := []string{
|
||||
"identity", "as", "group", "chat-id", "user", "open-dingtalk-id",
|
||||
"identity", "as", "group", "chat-id", "groups", "groups-file", "chat-query", "user", "user-query", "open-dingtalk-id",
|
||||
"users", "open-dingtalk-ids", "robot-code", "webhook-token",
|
||||
"uuid", "idempotency-key",
|
||||
}
|
||||
@@ -54,6 +63,251 @@ func TestMessagesSendPublishesCompleteIdentityConstraintInputs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendIdentityDescriptorMatchesRuntimeSurface(t *testing.T) {
|
||||
capabilities := MessageIdentityCapabilities()
|
||||
if len(capabilities) != 3 {
|
||||
t.Fatalf("identity capabilities = %#v", capabilities)
|
||||
}
|
||||
byIdentity := make(map[string]MessageIdentityCapability, len(capabilities))
|
||||
for _, capability := range capabilities {
|
||||
byIdentity[capability.Identity] = capability
|
||||
}
|
||||
if !byIdentity["user"].IdempotencyKeys || byIdentity["user"].BatchLedger {
|
||||
t.Fatalf("user capability = %#v", byIdentity["user"])
|
||||
}
|
||||
if !byIdentity["bot"].BatchLedger || byIdentity["bot"].IdempotencyKeys ||
|
||||
!reflect.DeepEqual(byIdentity["bot"].ContentTypes, []string{"text", "markdown"}) {
|
||||
t.Fatalf("bot capability = %#v", byIdentity["bot"])
|
||||
}
|
||||
if byIdentity["webhook"].BatchLedger || byIdentity["webhook"].IdempotencyKeys {
|
||||
t.Fatalf("webhook capability = %#v", byIdentity["webhook"])
|
||||
}
|
||||
capabilities[0].ContentTypes[0] = "mutated"
|
||||
if MessageIdentityCapabilities()[0].ContentTypes[0] == "mutated" {
|
||||
t.Fatal("identity capability descriptor leaked mutable storage")
|
||||
}
|
||||
if !messageIdentitySupportsContent("user", "audio") ||
|
||||
!messageIdentitySupportsContent("user", "video") ||
|
||||
messageIdentitySupportsContent("missing", "text") {
|
||||
t.Fatal("identity content normalization or unknown-identity guard drifted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageIMWorkflowContractsPublishRealPositiveAndNegativeBoundaries(t *testing.T) {
|
||||
card := CurrentCardWorkflowContract()
|
||||
if card.Version != "im.streaming-card.v1" || card.CallbackSupported ||
|
||||
!reflect.DeepEqual(card.ContentTypes, []string{"streaming-text"}) || len(card.FlowStatuses) != 5 {
|
||||
t.Fatalf("card contract = %#v", card)
|
||||
}
|
||||
card.Targets[0] = "mutated"
|
||||
if CurrentCardWorkflowContract().Targets[0] == "mutated" {
|
||||
t.Fatal("card contract leaked mutable storage")
|
||||
}
|
||||
|
||||
boundaries := CurrentIMCapabilityBoundaries()
|
||||
byName := make(map[string]bool, len(boundaries))
|
||||
for _, boundary := range boundaries {
|
||||
byName[boundary.Capability] = boundary.Supported
|
||||
if boundary.Alternative == "" {
|
||||
t.Errorf("boundary %s lacks alternative", boundary.Capability)
|
||||
}
|
||||
}
|
||||
for _, unsupported := range []string{"thread-write", "bot-rich-media", "card-action-callback", "resource-resume"} {
|
||||
if byName[unsupported] {
|
||||
t.Errorf("unsupported boundary %s was advertised", unsupported)
|
||||
}
|
||||
}
|
||||
for _, supported := range []string{"group-member-full-pagination", "group-owner-selection"} {
|
||||
if !byName[supported] {
|
||||
t.Errorf("supported boundary %s was hidden", supported)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendBotMultiGroupPublishesPerTargetLedger(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send", "--as", "bot", "--robot-code", "robot",
|
||||
"--groups", "cid-a,cid-b,cid-a", "--markdown", "通知", "--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 2 {
|
||||
t.Fatalf("multi-group calls = %#v", fake.calls)
|
||||
}
|
||||
for index, target := range []string{"cid-a", "cid-b"} {
|
||||
if fake.calls[index].tool != "send_robot_group_message" ||
|
||||
fake.calls[index].args["openConversationId"] != target {
|
||||
t.Fatalf("multi-group call[%d] = %#v", index, fake.calls[index])
|
||||
}
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["contractVersion"] != "im.batch-write.v1" || payload["ok"] != true ||
|
||||
payload["requestedCount"] != float64(2) || payload["succeededCount"] != float64(2) ||
|
||||
payload["failedCount"] != float64(0) {
|
||||
t.Fatalf("multi-group ledger = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendBotMultiGroupFailuresReturnNonzero(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fake *larkAlignmentCaller
|
||||
wantSucceeded float64
|
||||
wantFailed float64
|
||||
wantPartial bool
|
||||
}{
|
||||
{
|
||||
name: "partial failure",
|
||||
fake: &larkAlignmentCaller{failProductToolAt: map[string]int{
|
||||
"bot/send_robot_group_message": 2,
|
||||
}},
|
||||
wantSucceeded: 1,
|
||||
wantFailed: 1,
|
||||
wantPartial: true,
|
||||
},
|
||||
{
|
||||
name: "all failed",
|
||||
fake: &larkAlignmentCaller{failProductTool: "bot/send_robot_group_message"},
|
||||
wantSucceeded: 0,
|
||||
wantFailed: 2,
|
||||
wantPartial: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
helpers.InitDeps(tt.fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send", "--as", "bot", "--robot-code", "robot",
|
||||
"--groups", "cid-a,cid-b", "--markdown", "通知", "--yes",
|
||||
})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("failed multi-group delivery returned success")
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Category != apperrors.CategoryAPI ||
|
||||
typed.Reason != "batch_write_failed" || typed.ExitCode() == 0 {
|
||||
t.Fatalf("batch error = %#v (%v)", typed, err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["ok"] != false || payload["partial"] != tt.wantPartial ||
|
||||
payload["succeededCount"] != tt.wantSucceeded ||
|
||||
payload["failedCount"] != tt.wantFailed {
|
||||
t.Fatalf("failure ledger = %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendBotMultiGroupPropagatesOutputFailure(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
wantErr := errors.New("fixture output failed")
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(chatOutputErrorWriter{err: wantErr})
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send", "--as", "bot", "--robot-code", "robot",
|
||||
"--groups", "cid-a,cid-b", "--markdown", "通知", "--yes",
|
||||
})
|
||||
|
||||
if err := root.Execute(); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("output error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if len(fake.calls) != 2 {
|
||||
t.Fatalf("multi-group calls = %#v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendBotGroupsFileUsesSafeDeduplicatedTargets(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
if err := os.WriteFile("groups.txt", []byte("# comment\ncid-a,cid-b\ncid-a\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send", "--as", "bot", "--robot-code", "robot",
|
||||
"--groups-file", "groups.txt", "--text", "通知", "--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 2 {
|
||||
t.Fatalf("groups-file calls = %#v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatCreateExplicitOwnerSkipsCurrentProfileAndDeduplicatesMember(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-create", "--name", "测试群", "--users", "D-owner,user-1",
|
||||
"--owner-open-dingtalk-id", "D-owner", "--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "create_group_conversation" {
|
||||
t.Fatalf("explicit owner calls = %#v", fake.calls)
|
||||
}
|
||||
create := fake.calls[0].args
|
||||
if create["ownerOpenDingTalkId"] != "D-owner" {
|
||||
t.Fatalf("ownerOpenDingTalkId = %#v", create["ownerOpenDingTalkId"])
|
||||
}
|
||||
if got, want := create["groupMembers"], []string{"D-owner", "user-1"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("groupMembers = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatCreateOwnerQueryResolvesBeforeSingleCreate(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"owner-user","openDingTalkId":"D-owner"}]}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-create", "--name", "测试群", "--users", "user-1",
|
||||
"--owner-query", "张三", "--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 2 || fake.calls[0].tool != "search_contact_by_key_word" ||
|
||||
fake.calls[1].tool != "create_group_conversation" {
|
||||
t.Fatalf("owner query calls = %#v", fake.calls)
|
||||
}
|
||||
create := fake.calls[1].args
|
||||
if create["ownerOpenDingTalkId"] != "D-owner" {
|
||||
t.Fatalf("ownerOpenDingTalkId = %#v", create["ownerOpenDingTalkId"])
|
||||
}
|
||||
if got, want := create["groupMembers"], []string{"D-owner", "user-1"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("groupMembers = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSafeResourceDownloadsStayReadOnly(t *testing.T) {
|
||||
for _, command := range []shortcut.Shortcut{MessagesMget, MessagesResourceDownload} {
|
||||
if command.Risk != shortcut.RiskRead {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package chat
|
||||
|
||||
// MessageIdentityCapability is the reviewed Runtime capability descriptor for
|
||||
// +messages-send. Validation and Skill drift checks consume this same typed
|
||||
// source; it describes only behavior that the current lower transports expose.
|
||||
type MessageIdentityCapability struct {
|
||||
Identity string
|
||||
Targets []string
|
||||
ContentTypes []string
|
||||
NaturalTargets []string
|
||||
MentionTargets []string
|
||||
IdempotencyKeys bool
|
||||
BatchLedger bool
|
||||
}
|
||||
|
||||
var messageIdentityCapabilities = []MessageIdentityCapability{
|
||||
{
|
||||
Identity: "user",
|
||||
Targets: []string{"group", "direct-user", "direct-open-dingtalk-id"},
|
||||
ContentTypes: []string{"text", "markdown", "image-media-id", "file", "audio-as-file", "video-as-file"},
|
||||
NaturalTargets: []string{"chat-query", "user-query"},
|
||||
MentionTargets: []string{"open-dingtalk-id", "all"},
|
||||
IdempotencyKeys: true,
|
||||
BatchLedger: false,
|
||||
},
|
||||
{
|
||||
Identity: "bot",
|
||||
Targets: []string{"group", "groups", "direct-users", "direct-open-dingtalk-ids"},
|
||||
ContentTypes: []string{"text", "markdown"},
|
||||
NaturalTargets: []string{},
|
||||
MentionTargets: []string{"user-id", "open-dingtalk-id", "all"},
|
||||
IdempotencyKeys: false,
|
||||
BatchLedger: true,
|
||||
},
|
||||
{
|
||||
Identity: "webhook",
|
||||
Targets: []string{"token-owned-group"},
|
||||
ContentTypes: []string{"text", "markdown"},
|
||||
NaturalTargets: []string{},
|
||||
MentionTargets: []string{"user-id", "mobile", "all"},
|
||||
IdempotencyKeys: false,
|
||||
BatchLedger: false,
|
||||
},
|
||||
}
|
||||
|
||||
// MessageIdentityCapabilities returns defensive copies of the public matrix.
|
||||
func MessageIdentityCapabilities() []MessageIdentityCapability {
|
||||
out := make([]MessageIdentityCapability, len(messageIdentityCapabilities))
|
||||
for i, capability := range messageIdentityCapabilities {
|
||||
out[i] = capability
|
||||
out[i].Targets = append([]string(nil), capability.Targets...)
|
||||
out[i].ContentTypes = append([]string(nil), capability.ContentTypes...)
|
||||
out[i].NaturalTargets = append([]string(nil), capability.NaturalTargets...)
|
||||
out[i].MentionTargets = append([]string(nil), capability.MentionTargets...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func messageIdentitySupportsContent(identity, contentType string) bool {
|
||||
if contentType == "audio" || contentType == "video" {
|
||||
contentType += "-as-file"
|
||||
} else if contentType == "image" {
|
||||
contentType = "image-media-id"
|
||||
}
|
||||
for _, capability := range messageIdentityCapabilities {
|
||||
if capability.Identity != identity {
|
||||
continue
|
||||
}
|
||||
for _, supported := range capability.ContentTypes {
|
||||
if supported == contentType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package chat
|
||||
|
||||
// CardFlowStatus is one reviewed state accepted by DingTalk's streaming-card
|
||||
// update transport.
|
||||
type CardFlowStatus struct {
|
||||
Value int
|
||||
Name string
|
||||
}
|
||||
|
||||
// CardWorkflowContract describes the real card subset exposed by the current
|
||||
// Runtime. It intentionally does not claim Lark card JSON/component compilation
|
||||
// or callback consumption, neither of which exists in the lower interface.
|
||||
type CardWorkflowContract struct {
|
||||
Version string
|
||||
Targets []string
|
||||
ContentTypes []string
|
||||
FlowStatuses []CardFlowStatus
|
||||
CallbackSupported bool
|
||||
}
|
||||
|
||||
var currentCardWorkflowContract = CardWorkflowContract{
|
||||
Version: "im.streaming-card.v1",
|
||||
Targets: []string{"group", "direct-user", "direct-open-dingtalk-id"},
|
||||
ContentTypes: []string{"streaming-text"},
|
||||
FlowStatuses: []CardFlowStatus{
|
||||
{Value: 1, Name: "processing"},
|
||||
{Value: 2, Name: "typing"},
|
||||
{Value: 3, Name: "completed"},
|
||||
{Value: 4, Name: "executing"},
|
||||
{Value: 5, Name: "error"},
|
||||
},
|
||||
CallbackSupported: false,
|
||||
}
|
||||
|
||||
// CurrentCardWorkflowContract returns a defensive copy for policy and docs.
|
||||
func CurrentCardWorkflowContract() CardWorkflowContract {
|
||||
contract := currentCardWorkflowContract
|
||||
contract.Targets = append([]string(nil), contract.Targets...)
|
||||
contract.ContentTypes = append([]string(nil), contract.ContentTypes...)
|
||||
contract.FlowStatuses = append([]CardFlowStatus(nil), contract.FlowStatuses...)
|
||||
return contract
|
||||
}
|
||||
|
||||
func validCardFlowStatus(value int) bool {
|
||||
for _, status := range currentCardWorkflowContract.FlowStatuses {
|
||||
if status.Value == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IMCapabilityBoundary makes unsupported Lark-parity requests explicit and
|
||||
// testable instead of leaving them to prose or transport guessing.
|
||||
type IMCapabilityBoundary struct {
|
||||
Capability string
|
||||
Supported bool
|
||||
Alternative string
|
||||
}
|
||||
|
||||
var currentIMCapabilityBoundaries = []IMCapabilityBoundary{
|
||||
{Capability: "thread-write", Supported: false, Alternative: "quote reply with +messages-reply; thread reading with +thread-replies"},
|
||||
{Capability: "bot-rich-media", Supported: false, Alternative: "bot text/markdown, or current-user file/image send"},
|
||||
{Capability: "card-action-callback", Supported: false, Alternative: "streaming text card create/update only"},
|
||||
{Capability: "resource-resume", Supported: false, Alternative: "atomic whole-file download with explicit retry"},
|
||||
{Capability: "group-member-full-pagination", Supported: true, Alternative: "+chat-members-list or +group-members"},
|
||||
{Capability: "group-owner-selection", Supported: true, Alternative: "+chat-create owner flags"},
|
||||
}
|
||||
|
||||
// CurrentIMCapabilityBoundaries returns the reviewed positive/negative matrix.
|
||||
func CurrentIMCapabilityBoundaries() []IMCapabilityBoundary {
|
||||
return append([]IMCapabilityBoundary(nil), currentIMCapabilityBoundaries...)
|
||||
}
|
||||
@@ -13,44 +13,81 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
)
|
||||
|
||||
// ChatCreate creates a DingTalk group as the current user. It intentionally
|
||||
// does not advertise Lark-only owner, description, initial-bot, or visibility
|
||||
// semantics.
|
||||
// ChatCreate creates a DingTalk group after resolving every natural member and
|
||||
// the optional owner to stable DingTalk identities. Description, initial-bot,
|
||||
// idempotency, and Lark visibility semantics remain deliberately unsupported.
|
||||
var ChatCreate = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-create",
|
||||
Product: "im",
|
||||
Description: "以当前用户身份创建钉钉群聊",
|
||||
Intent: "当你要创建一个基础钉钉群聊时使用;自动把当前用户加入成员列表并作为群主,支持 INTERNAL、EXTERNAL、NORMAL 和话题模式。它不支持指定其他 owner、群 description、初始机器人或 Lark public/private 语义。",
|
||||
Description: "按成员和可选群主全量预检后创建一个钉钉群聊",
|
||||
Intent: "当你要创建钉钉群聊时使用;成员可传稳定 ID 或 --member-query 姓名,群主默认当前用户,也可用 --owner-open-dingtalk-id 或 --owner-query 明确指定。所有自然身份会在唯一解析并去重后才执行一次创建,任一零命中或多命中都会整体停止。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "name", Type: shortcut.FlagString, Desc: "群名称", Required: true},
|
||||
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "初始成员 userId 或 openDingTalkId 列表", Required: true},
|
||||
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "初始成员 userId 或 openDingTalkId 列表"},
|
||||
{Name: "member-query", Type: shortcut.FlagStringSlice, Desc: "按姓名/花名唯一解析的初始成员,可逗号分隔或重复传入"},
|
||||
{Name: "owner-open-dingtalk-id", Type: shortcut.FlagString, Desc: "明确指定群主 openDingTalkId(与 --owner-query 互斥;省略时群主为当前用户)"},
|
||||
{Name: "owner-query", Type: shortcut.FlagString, Desc: "按姓名唯一解析群主 openDingTalkId(与 --owner-open-dingtalk-id 互斥)"},
|
||||
{Name: "type", Type: shortcut.FlagString, Default: "INTERNAL", Desc: "群类型", Enum: []string{"INTERNAL", "EXTERNAL", "NORMAL"}},
|
||||
{Name: "thread", Type: shortcut.FlagBool, Desc: "创建为话题群"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"users", "member-query"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"owner-open-dingtalk-id", "owner-query"}},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws chat +chat-create --name "项目冲刺群" --users userId1,userId2`,
|
||||
`dws chat +chat-create --name "合作群" --users userId1,userId2 --type EXTERNAL`,
|
||||
`dws chat +chat-create --name "合作群" --member-query "张三,李四" --type EXTERNAL`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
profile, err := rt.CallMCPData("contact", "get_current_user_profile", nil)
|
||||
resolvedMembers, err := targetresolver.ResolveUsers(
|
||||
rt,
|
||||
rt.StrSlice("member-query"),
|
||||
targetresolver.IdentityAny,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取当前用户以设置群主失败: %w", err)
|
||||
return err
|
||||
}
|
||||
currentUserID := currentProfileUserID(profile)
|
||||
if currentUserID == "" {
|
||||
return apperrors.NewValidation("当前用户资料缺少 userId,无法保证群主属于初始成员列表")
|
||||
ownerOpenID := rt.Str("owner-open-dingtalk-id")
|
||||
if query := rt.Str("owner-query"); query != "" {
|
||||
resolvedOwner, resolveErr := targetresolver.ResolveUser(
|
||||
rt, query, targetresolver.IdentityOpenDingTalkID)
|
||||
if resolveErr != nil {
|
||||
return resolveErr
|
||||
}
|
||||
ownerOpenID = resolvedOwner.Selected.OpenDingTalkID
|
||||
}
|
||||
members := make([]string, 0, len(rt.StrSlice("users"))+len(resolvedMembers)+1)
|
||||
if ownerOpenID != "" {
|
||||
members = append(members, ownerOpenID)
|
||||
} else {
|
||||
profile, profileErr := rt.CallMCPData("contact", "get_current_user_profile", nil)
|
||||
if profileErr != nil {
|
||||
return fmt.Errorf("读取当前用户以设置群主失败: %w", profileErr)
|
||||
}
|
||||
currentUserID := currentProfileUserID(profile)
|
||||
if currentUserID == "" {
|
||||
return apperrors.NewValidation("当前用户资料缺少 userId,无法保证群主属于初始成员列表")
|
||||
}
|
||||
members = append(members, currentUserID)
|
||||
}
|
||||
members := []string{currentUserID}
|
||||
for _, member := range rt.StrSlice("users") {
|
||||
member = strings.TrimSpace(member)
|
||||
if member != "" {
|
||||
members = appendUniqueShortcutString(members, member)
|
||||
}
|
||||
}
|
||||
for _, resolved := range resolvedMembers {
|
||||
member := resolved.Selected.UserID
|
||||
if member == "" {
|
||||
member = resolved.Selected.OpenDingTalkID
|
||||
}
|
||||
members = appendUniqueShortcutString(members, member)
|
||||
}
|
||||
params := map[string]any{
|
||||
"groupName": rt.Str("name"),
|
||||
"groupMembers": members,
|
||||
@@ -59,6 +96,9 @@ var ChatCreate = shortcut.Shortcut{
|
||||
if rt.Bool("thread") {
|
||||
params["convThreadEnabled"] = true
|
||||
}
|
||||
if ownerOpenID != "" {
|
||||
params["ownerOpenDingTalkId"] = ownerOpenID
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.CallMCP("create_group_conversation", params)
|
||||
}
|
||||
@@ -114,18 +154,23 @@ func normalizeCreatedConversation(data map[string]any) {
|
||||
var ChatUpdate = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-update",
|
||||
Aliases: []string{"+chat-rename"},
|
||||
Product: "chat",
|
||||
Description: "更新群名称(仅名称,不支持 description)",
|
||||
Intent: "当你只需要修改群名称时使用;这是 lark-cli +chat-update 的诚实子集,只接受群 openConversationId 和新名称。修改群 description、个人备注、群昵称或其他群设置时不要使用。",
|
||||
Intent: "当你只需要修改群名称时使用;--group 可传群名或 openConversationId,群名必须唯一解析后才会写入。修改群 description、个人备注、群昵称或其他群设置时不要使用。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群名称或 openConversationId", Required: true},
|
||||
{Name: "name", Type: shortcut.FlagString, Desc: "新的群名称", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-update --group <openConversationId> --name "新群名"`},
|
||||
Tips: []string{`dws chat +chat-update --group <群名或openConversationId> --name "新群名"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
resolved, err := targetresolver.ResolveChatTarget(rt, rt.Str("group"), "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.CallMCP("update_group_name", map[string]any{
|
||||
"openconversation_id": rt.Str("group"),
|
||||
"openconversation_id": resolved.Selected.OpenConversationID,
|
||||
"group_name": rt.Str("name"),
|
||||
})
|
||||
},
|
||||
@@ -138,8 +183,8 @@ var MessagesReply = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+messages-reply",
|
||||
Product: "chat",
|
||||
Description: "以当前用户身份引用回复消息(自动补全原发送者)",
|
||||
Intent: "当你要以当前用户身份对已有消息发送纯文本引用回复时使用;提供会话和被引用消息即可,默认通过 mget 自动读取原发送者,也可显式传 openDingTalkId/userId;userId 会通过通讯录搜索精确匹配 openDingTalkId。它不支持 bot 身份、富媒体、卡片或 thread 内回复。",
|
||||
Description: "引用回复一条已有消息,并返回可继续查询或撤回的发送上下文",
|
||||
Intent: "当你要以当前用户身份对一条已有消息发送纯文本引用回复时使用;传会话和原消息 ID,CLI 会先读取原发送者,也可显式传 --ref-sender。成功结果在保留下层响应的同时增量返回 messageId(下层提供时)、conversationId、threadId(适用时)、deliveryStatus、idempotencyKey 和 referencedMessage 来源上下文。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||||
@@ -175,10 +220,79 @@ var MessagesReply = shortcut.Shortcut{
|
||||
if value := rt.StrFirst("idempotency-key", "uuid"); value != "" {
|
||||
params["uuid"] = value
|
||||
}
|
||||
return rt.CallMCP("send_personal_message", params)
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{
|
||||
"contractVersion": "im.message-reply.v1",
|
||||
"dryRun": true,
|
||||
"willSend": false,
|
||||
"transport": "chat/send_personal_message",
|
||||
"arguments": params,
|
||||
"conversationId": rt.Str("conversation-id"),
|
||||
"referencedMessage": map[string]any{
|
||||
"messageId": replyMessageID(rt),
|
||||
"senderOpenDingTalkId": refSender,
|
||||
},
|
||||
})
|
||||
}
|
||||
data, err := rt.CallMCPWriteData("chat", "send_personal_message", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enrichReplyResult(data, rt, refSender)
|
||||
return rt.Output(data)
|
||||
},
|
||||
}
|
||||
|
||||
func enrichReplyResult(data map[string]any, rt *shortcut.RuntimeContext, refSender string) {
|
||||
data["contractVersion"] = "im.message-reply.v1"
|
||||
data["conversationId"] = rt.Str("conversation-id")
|
||||
data["referencedMessage"] = map[string]any{
|
||||
"messageId": replyMessageID(rt),
|
||||
"senderOpenDingTalkId": refSender,
|
||||
"resolutionSource": func() string {
|
||||
if rt.Str("ref-sender") != "" {
|
||||
return "explicit"
|
||||
}
|
||||
return "message_lookup"
|
||||
}(),
|
||||
}
|
||||
if key := rt.StrFirst("idempotency-key", "uuid"); key != "" {
|
||||
data["idempotencyKey"] = key
|
||||
}
|
||||
if value := replyResponseValue(data, "openMessageId", "openMsgId", "messageId", "msgId"); value != nil {
|
||||
data["messageId"] = value
|
||||
}
|
||||
if value := replyResponseValue(data, "openConvThreadId", "threadId", "topicId"); value != nil {
|
||||
data["threadId"] = value
|
||||
}
|
||||
if value := replyResponseValue(data, "deliveryStatus", "sendStatus", "status"); value != nil {
|
||||
data["deliveryStatus"] = value
|
||||
data["deliveryStatusKnown"] = true
|
||||
} else {
|
||||
data["deliveryStatus"] = "unknown"
|
||||
data["deliveryStatusKnown"] = false
|
||||
}
|
||||
}
|
||||
|
||||
func replyResponseValue(data map[string]any, keys ...string) any {
|
||||
scopes := []map[string]any{data}
|
||||
for _, wrapper := range []string{"result", "data"} {
|
||||
if nested, ok := data[wrapper].(map[string]any); ok {
|
||||
scopes = append(scopes, nested)
|
||||
}
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
for _, key := range keys {
|
||||
if value, ok := scope[key]; ok && value != nil {
|
||||
if text, isString := value.(string); !isString || strings.TrimSpace(text) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveReplySender(rt *shortcut.RuntimeContext) (string, error) {
|
||||
if value := rt.Str("ref-sender"); value != "" {
|
||||
if isOpenID(value) {
|
||||
@@ -303,8 +417,8 @@ var FlagCreate = shortcut.Shortcut{
|
||||
Intent: "当你要把同一会话中的一条或多条消息加入当前用户的个人收藏时使用;逐项返回成功/失败 ledger。这是消息 favorite,不是消息 Pin、会话置顶或 feed-layer thread flag。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "message-id", Type: shortcut.FlagString, Desc: "单条消息 openMessageId"},
|
||||
{Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "多条消息 openMessageId(最多 10 条)"},
|
||||
{Name: "message-id", Type: shortcut.FlagString, Desc: "单条消息 openMessageId;消息 ID 去重后必须为 1-10 条"},
|
||||
{Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "多条消息 openMessageId;消息 ID 去重后必须为 1-10 条"},
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "消息所在会话 openConversationId", Required: true},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
@@ -330,8 +444,8 @@ var FlagCancel = shortcut.Shortcut{
|
||||
Intent: "当你要移除当前用户对同一会话中一条或多条消息的个人收藏标记时使用;逐项返回成功/失败 ledger,只影响 message favorite,不删除原消息,也不会修改 Pin 或会话置顶。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "message-id", Type: shortcut.FlagString, Desc: "单条消息 openMessageId"},
|
||||
{Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "多条消息 openMessageId(最多 10 条)"},
|
||||
{Name: "message-id", Type: shortcut.FlagString, Desc: "单条消息 openMessageId;消息 ID 去重后必须为 1-10 条"},
|
||||
{Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "多条消息 openMessageId;消息 ID 去重后必须为 1-10 条"},
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "消息所在会话 openConversationId", Required: true},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
@@ -389,8 +503,8 @@ var FlagList = shortcut.Shortcut{
|
||||
Intent: "当你要查看当前用户的 DingTalk message favorite 列表时使用;返回下层分页结果,不把 message favorite 与 Pin、会话置顶或 Lark feed-layer thread flag 混为一谈。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "数字分页游标,首次传 0"},
|
||||
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,范围 1-100"},
|
||||
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "数字分页游标;--cursor 必须大于等于 0,--size 必须在 1-100 之间"},
|
||||
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量;--cursor 必须大于等于 0,--size 必须在 1-100 之间"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{
|
||||
Kind: shortcut.ConstraintCustom,
|
||||
@@ -516,10 +630,10 @@ var ChatList = shortcut.Shortcut{
|
||||
"不支持 lark 的 sort/sort-type,也不模拟 bot 身份剥离 p2p。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "types", Type: shortcut.FlagStringSlice, Desc: "会话类型:group、p2p;省略时默认只返回群聊"},
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量(1-100)"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "--page-size 的别名"},
|
||||
{Name: "page-token", Type: shortcut.FlagString, Desc: "分页游标(钉钉为整数游标的字符串形式)"},
|
||||
{Name: "types", Type: shortcut.FlagStringSlice, Desc: "会话类型只能包含 group 和/或 p2p;省略时默认只返回群聊"},
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,必须在 1-100 之间"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "--page-size 的别名,必须在 1-100 之间"},
|
||||
{Name: "page-token", Type: shortcut.FlagString, Desc: "分页游标;若提供则必须是非负整数"},
|
||||
{Name: "cursor", Type: shortcut.FlagInt, Desc: "--page-token 的整数别名"},
|
||||
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"},
|
||||
},
|
||||
@@ -721,7 +835,7 @@ func chatListFilterTypes(chats []map[string]any, types []string) []map[string]an
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
shortcut.Register(withReviewedChatShortcutContracts(
|
||||
ChatCreate,
|
||||
ChatList,
|
||||
ChatUpdate,
|
||||
@@ -730,5 +844,5 @@ func init() {
|
||||
FlagCancel,
|
||||
FlagList,
|
||||
FeedGroupQueryItem,
|
||||
)
|
||||
)...)
|
||||
}
|
||||
|
||||
@@ -23,11 +23,14 @@ type larkAlignmentCall struct {
|
||||
}
|
||||
|
||||
type larkAlignmentCaller struct {
|
||||
calls []larkAlignmentCall
|
||||
failTarget string
|
||||
failProductTool string
|
||||
category string
|
||||
responses map[string]string
|
||||
calls []larkAlignmentCall
|
||||
failTarget string
|
||||
failProductTool string
|
||||
failProductToolAt map[string]int
|
||||
callCounts map[string]int
|
||||
category string
|
||||
responses map[string]string
|
||||
sequenceResponses map[string][]string
|
||||
}
|
||||
|
||||
func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
@@ -36,9 +39,16 @@ func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string,
|
||||
return nil, errors.New("fixture write failed")
|
||||
}
|
||||
key := product + "/" + tool
|
||||
if f.callCounts == nil {
|
||||
f.callCounts = map[string]int{}
|
||||
}
|
||||
f.callCounts[key]++
|
||||
if f.failProductTool == key {
|
||||
return nil, errors.New("fixture lower call failed")
|
||||
}
|
||||
if f.failProductToolAt[key] == f.callCounts[key] {
|
||||
return nil, errors.New("fixture sequenced lower call failed")
|
||||
}
|
||||
text := `{"success":true}`
|
||||
switch key {
|
||||
case "contact/get_current_user_profile":
|
||||
@@ -75,6 +85,10 @@ func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string,
|
||||
if response, ok := f.responses[key]; ok {
|
||||
text = response
|
||||
}
|
||||
if responses := f.sequenceResponses[key]; len(responses) > 0 {
|
||||
text = responses[0]
|
||||
f.sequenceResponses[key] = responses[1:]
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
@@ -87,7 +101,67 @@ func (f *larkAlignmentCaller) DryRun() bool { return false }
|
||||
func (f *larkAlignmentCaller) Fields() string { return "" }
|
||||
func (f *larkAlignmentCaller) JQ() string { return "" }
|
||||
|
||||
func TestChatCreateAddsCurrentUserAndNormalizesResult(t *testing.T) {
|
||||
func TestCrossPlatformCoverageEvaluationRegressionNaturalGroupTargetsAndRecallInference(t *testing.T) {
|
||||
t.Run("group name to bots", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/search_groups": `{"result":[{"openConversationId":"cid-project","title":"项目群"}],"hasMore":false}`,
|
||||
"bot/list_group_bots": `{"result":{"bots":[]}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+chat-bots", "--group", "项目群"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 2 || fake.calls[1].tool != "list_group_bots" || fake.calls[1].args["openConversationId"] != "cid-project" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("group query to invite url", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/search_groups": `{"result":[{"openConversationId":"cid-project","title":"项目群"}],"hasMore":false}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+chat-invite-url", "--chat-query", "项目群"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 2 || fake.calls[1].tool != "get_group_invite_url" || fake.calls[1].args["openConversationId"] != "cid-project" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stable id in group query bypasses search", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+chat-invite-url", "--chat-query", "cid-fixture-chat-0001"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "get_group_invite_url" ||
|
||||
fake.calls[0].args["openConversationId"] != "cid-fixture-chat-0001" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("message id fills conversation before recall", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+messages-recall", "--message-ids", "msg", "--yes"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 2 || fake.calls[0].tool != "list_messages_by_ids" || fake.calls[1].tool != "recall_message" || fake.calls[1].args["openConversationId"] != "cid" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatCreateAddsCurrentUserAndNormalizesResult(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
@@ -127,7 +201,80 @@ func TestChatCreateAddsCurrentUserAndNormalizesResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesSendRoutesIdentitySpecificTransports(t *testing.T) {
|
||||
func TestCrossPlatformCoverageChatCreateResolvesEveryNaturalMemberBeforeCreating(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"resolved-user","openDingTalkId":"D-resolved"}]}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-create",
|
||||
"--name", "测试群",
|
||||
"--users", "explicit-user",
|
||||
"--member-query", "张三",
|
||||
"--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 3 {
|
||||
t.Fatalf("calls = %#v, want member resolve + current profile + create", fake.calls)
|
||||
}
|
||||
if fake.calls[0].tool != "search_contact_by_key_word" ||
|
||||
fake.calls[1].tool != "get_current_user_profile" ||
|
||||
fake.calls[2].tool != "create_group_conversation" {
|
||||
t.Fatalf("call order = %#v", fake.calls)
|
||||
}
|
||||
if got, want := fake.calls[2].args["groupMembers"], []string{"self-user", "explicit-user", "resolved-user"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("groupMembers = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatCreateNaturalMemberAmbiguityStopsBeforeProfileAndCreate(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"u1"},{"name":"张三","userId":"u2"}]}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-create",
|
||||
"--name", "测试群",
|
||||
"--member-query", "张三",
|
||||
"--yes",
|
||||
})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("ambiguous member unexpectedly created a group")
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "search_contact_by_key_word" {
|
||||
t.Fatalf("ambiguous member reached profile/create: %#v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatCreateNaturalMemberDryRunUsesSameResolutionChain(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"resolved-user"}]}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-create",
|
||||
"--name", "测试群",
|
||||
"--member-query", "张三",
|
||||
"--dry-run",
|
||||
"--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 3 ||
|
||||
fake.calls[0].tool != "search_contact_by_key_word" ||
|
||||
fake.calls[1].tool != "get_current_user_profile" ||
|
||||
fake.calls[2].tool != "create_group_conversation" {
|
||||
t.Fatalf("dry-run calls = %#v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendRoutesIdentitySpecificTransports(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
@@ -201,7 +348,7 @@ func TestMessagesSendRoutesIdentitySpecificTransports(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesSendRejectsUnsupportedIdentityCapability(t *testing.T) {
|
||||
func TestCrossPlatformCoverageMessagesSendRejectsUnsupportedIdentityCapability(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
@@ -222,7 +369,7 @@ func TestMessagesSendRejectsUnsupportedIdentityCapability(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLarkAlignmentWriteMappings(t *testing.T) {
|
||||
func TestCrossPlatformCoverageLarkAlignmentWriteMappings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
@@ -232,11 +379,11 @@ func TestLarkAlignmentWriteMappings(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "chat-update-name-only",
|
||||
args: []string{"chat", "+chat-update", "--group", "cid", "--name", "新群名", "--yes"},
|
||||
args: []string{"chat", "+chat-update", "--group", "cid-fixture-chat-0001", "--name", "新群名", "--yes"},
|
||||
product: "chat",
|
||||
tool: "update_group_name",
|
||||
wantArgs: map[string]any{
|
||||
"openconversation_id": "cid",
|
||||
"openconversation_id": "cid-fixture-chat-0001",
|
||||
"group_name": "新群名",
|
||||
},
|
||||
},
|
||||
@@ -291,10 +438,32 @@ func TestLarkAlignmentWriteMappings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesReplyPublishesPlainTextBoundary(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
func TestCrossPlatformCoverageObservedChatRenameAliasResolvesNameBeforeWrite(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/search_groups": `{"result":[{"openConversationId":"cid-project","title":"项目评测群"}],"hasMore":false}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+chat-rename", "--group", "项目评测群", "--name", "项目讨论群", "--yes"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 2 || fake.calls[0].tool != "search_groups" || fake.calls[1].tool != "update_group_name" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
if fake.calls[1].args["openconversation_id"] != "cid-project" || fake.calls[1].args["group_name"] != "项目讨论群" {
|
||||
t.Fatalf("write args = %#v", fake.calls[1].args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesReplyPublishesPlainTextBoundary(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"chat/send_personal_message": `{"result":{"openMessageId":"new-msg","openConvThreadId":"thread-1","sendStatus":"accepted"}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-reply",
|
||||
"--conversation-id", "cid",
|
||||
@@ -327,9 +496,46 @@ func TestMessagesReplyPublishesPlainTextBoundary(t *testing.T) {
|
||||
content["content"] != "收到" {
|
||||
t.Fatalf("reply content = %#v", content)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["contractVersion"] != "im.message-reply.v1" ||
|
||||
payload["messageId"] != "new-msg" ||
|
||||
payload["conversationId"] != "cid" ||
|
||||
payload["threadId"] != "thread-1" ||
|
||||
payload["deliveryStatus"] != "accepted" ||
|
||||
payload["idempotencyKey"] != "reply-uuid" {
|
||||
t.Fatalf("reply result context = %#v", payload)
|
||||
}
|
||||
referenced, _ := payload["referencedMessage"].(map[string]any)
|
||||
if referenced["messageId"] != "msg" || referenced["resolutionSource"] != "explicit" {
|
||||
t.Fatalf("referenced message context = %#v", referenced)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagBatchContinuesAndPublishesFailureLedger(t *testing.T) {
|
||||
func TestCrossPlatformCoverageMessagesReplyDryRunStopsBeforeWrite(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-reply",
|
||||
"--conversation-id", "cid",
|
||||
"--message-id", "msg",
|
||||
"--ref-sender", "D-sender",
|
||||
"--text", "收到",
|
||||
"--dry-run",
|
||||
"--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("reply dry-run reached write transport: %#v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagBatchContinuesAndPublishesFailureLedger(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{failTarget: "m2"}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
@@ -341,8 +547,8 @@ func TestFlagBatchContinuesAndPublishesFailureLedger(t *testing.T) {
|
||||
"--conversation-id", "cid",
|
||||
"--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("partial batch failure returned success")
|
||||
}
|
||||
if len(fake.calls) != 2 {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
@@ -359,7 +565,7 @@ func TestFlagBatchContinuesAndPublishesFailureLedger(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationSetTopBatchDryRunPublishesActionsWithoutWrites(t *testing.T) {
|
||||
func TestCrossPlatformCoverageConversationSetTopBatchDryRunPublishesActionsWithoutWrites(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
@@ -390,7 +596,7 @@ func TestConversationSetTopBatchDryRunPublishesActionsWithoutWrites(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesMgetDryRunPublishesMultiResourceDownloadPlan(t *testing.T) {
|
||||
func TestCrossPlatformCoverageMessagesMgetDryRunPublishesMultiResourceDownloadPlan(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
@@ -450,7 +656,7 @@ func TestCrossPlatformCoverageMessagesReplyResolvesUserIDBeforeExecution(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesReplyInfersSenderFromReferencedMessage(t *testing.T) {
|
||||
func TestCrossPlatformCoverageMessagesReplyInfersSenderFromReferencedMessage(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
@@ -479,7 +685,7 @@ func TestMessagesReplyInfersSenderFromReferencedMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindMessageSenderOpenDingTalkIDIgnoresUnrelatedNestedIdentity(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFindMessageSenderOpenDingTalkIDIgnoresUnrelatedNestedIdentity(t *testing.T) {
|
||||
message := map[string]any{
|
||||
"content": map[string]any{
|
||||
"mentions": []any{
|
||||
@@ -627,7 +833,7 @@ func TestChatListP2POnlyDropsGroups(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedGroupQueryProjectPreservesRequestOrderAndMissingLedger(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFeedGroupQueryProjectPreservesRequestOrderAndMissingLedger(t *testing.T) {
|
||||
conversations := []map[string]any{
|
||||
{"openConversationId": "cid-a", "conversationName": "A"},
|
||||
{"openConversationId": "cid-b", "conversationName": "B"},
|
||||
@@ -645,7 +851,7 @@ func TestFeedGroupQueryProjectPreservesRequestOrderAndMissingLedger(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedGroupQueryDoesNotMisreportMissingItemWhenSourceHasMore(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFeedGroupQueryDoesNotMisreportMissingItemWhenSourceHasMore(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{
|
||||
category: `{"result":{"hasMore":true,"list":[{"openConversationId":"cid-a","conversationName":"A"}]}}`,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
// ValidateMessageExportOutput applies the same workspace-relative and
|
||||
// no-symlink boundary used by message resource downloads. The export target is
|
||||
// always a file; directory-shaped paths are rejected instead of inventing a
|
||||
// name.
|
||||
func ValidateMessageExportOutput(output string) error {
|
||||
trimmed := strings.TrimSpace(output)
|
||||
if strings.HasSuffix(trimmed, "/") ||
|
||||
strings.HasSuffix(trimmed, string(os.PathSeparator)) {
|
||||
return apperrors.NewValidation("--output 必须是 JSON 文件路径,不能是目录")
|
||||
}
|
||||
if !strings.EqualFold(filepath.Ext(trimmed), ".json") {
|
||||
return apperrors.NewValidation("--output 必须使用 .json 文件扩展名")
|
||||
}
|
||||
return validateResourceDownloadOutputFlag(output, "--output")
|
||||
}
|
||||
|
||||
// WriteMessageExportJSON atomically publishes the exact structured message
|
||||
// ledger. It defaults to no-clobber and never follows a symlink outside the
|
||||
// current working directory.
|
||||
func WriteMessageExportJSON(output string, overwrite bool, payload any) (relativePath string, size int, err error) {
|
||||
if err := ValidateMessageExportOutput(output); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
cwd, err := resourceGetwd()
|
||||
if err != nil {
|
||||
return "", 0, apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err))
|
||||
}
|
||||
base, err := resourceAbs(cwd)
|
||||
if err != nil {
|
||||
return "", 0, apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
|
||||
}
|
||||
realBase, err := resourceEvalSymlinks(base)
|
||||
if err != nil {
|
||||
return "", 0, apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
|
||||
}
|
||||
|
||||
target := filepath.Join(realBase, filepath.Clean(strings.TrimSpace(output)))
|
||||
parent := filepath.Dir(target)
|
||||
if err := ensureResourceDownloadParent(realBase, parent); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
realParent, err := resourceEvalSymlinks(parent)
|
||||
if err != nil {
|
||||
return "", 0, apperrors.NewInternal(fmt.Sprintf("解析输出目录失败: %v", err))
|
||||
}
|
||||
parentRel, err := resourceRel(realBase, realParent)
|
||||
if err != nil || parentRel == ".." || strings.HasPrefix(parentRel, ".."+string(os.PathSeparator)) {
|
||||
return "", 0, apperrors.NewValidation("--output 解析后逃逸工作目录")
|
||||
}
|
||||
target = filepath.Join(realParent, filepath.Base(target))
|
||||
if info, statErr := resourceLstat(target); statErr == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return "", 0, apperrors.NewValidation("--output 目标不能是符号链接")
|
||||
}
|
||||
if info.IsDir() {
|
||||
return "", 0, apperrors.NewValidation("--output 目标是目录,无法写入 JSON 文件")
|
||||
}
|
||||
if !overwrite {
|
||||
return "", 0, apperrors.NewValidation("目标文件已存在;如确认覆盖请显式传 --overwrite")
|
||||
}
|
||||
} else if !errors.Is(statErr, os.ErrNotExist) {
|
||||
return "", 0, apperrors.NewInternal(fmt.Sprintf("检查输出文件失败: %v", statErr))
|
||||
}
|
||||
|
||||
rendered, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return "", 0, apperrors.NewInternal(fmt.Sprintf("编码消息导出失败: %v", err))
|
||||
}
|
||||
rendered = append(rendered, '\n')
|
||||
temp, err := resourceCreateTemp(realParent, "."+filepath.Base(target)+".part-*")
|
||||
if err != nil {
|
||||
return "", 0, apperrors.NewInternal(fmt.Sprintf("创建消息导出临时文件失败: %v", err))
|
||||
}
|
||||
tempPath := temp.Name()
|
||||
defer func() {
|
||||
_ = resourceTempClose(temp)
|
||||
_ = os.Remove(tempPath)
|
||||
}()
|
||||
if _, err := temp.Write(rendered); err != nil {
|
||||
return "", 0, apperrors.NewInternal(fmt.Sprintf("写入消息导出失败: %v", err))
|
||||
}
|
||||
if err := resourceTempSync(temp); err != nil {
|
||||
return "", 0, apperrors.NewInternal(fmt.Sprintf("同步消息导出失败: %v", err))
|
||||
}
|
||||
if err := resourceTempClose(temp); err != nil {
|
||||
return "", 0, apperrors.NewInternal(fmt.Sprintf("关闭消息导出失败: %v", err))
|
||||
}
|
||||
if overwrite {
|
||||
if err := resourceRename(tempPath, target); err != nil {
|
||||
return "", 0, apperrors.NewInternal(fmt.Sprintf("发布消息导出失败: %v", err))
|
||||
}
|
||||
} else if err := resourceLink(tempPath, target); err != nil {
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
return "", 0, apperrors.NewValidation("目标文件已存在;如确认覆盖请显式传 --overwrite")
|
||||
}
|
||||
return "", 0, apperrors.NewInternal(fmt.Sprintf("发布消息导出失败: %v", err))
|
||||
}
|
||||
relativePath, err = resourceRel(realBase, target)
|
||||
if err != nil {
|
||||
return "", 0, apperrors.NewInternal(fmt.Sprintf("解析输出相对路径失败: %v", err))
|
||||
}
|
||||
return filepath.ToSlash(relativePath), len(rendered), nil
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -44,7 +45,7 @@ var (
|
||||
resourceCopy = io.Copy
|
||||
resourceTempSync = (*os.File).Sync
|
||||
resourceTempClose = (*os.File).Close
|
||||
resourceRename = os.Rename
|
||||
resourceRename = replaceFileAtomically
|
||||
resourceLink = os.Link
|
||||
resourceDownload = downloadResourceAtomically
|
||||
)
|
||||
@@ -58,15 +59,15 @@ var MessagesResourceDownload = shortcut.Shortcut{
|
||||
Description: "安全下载消息资源(图片/视频/语音/文件)到本地",
|
||||
Intent: "当你需要拿到消息里的实际图片、视频、语音或钉盘文件,而不只是资源 ID 时使用;" +
|
||||
"mediaId 用消息和会话身份换取下载地址,fileId 复用钉盘下载能力,再安全写入工作目录内的相对路径。" +
|
||||
"默认不覆盖已有文件,只有显式传 --overwrite 才覆盖;按既有安全本地下载约定无需交互确认。",
|
||||
"默认不覆盖已有文件,只有显式传 --overwrite 才覆盖;下载采用整文件临时落盘后原子发布,不支持 Range 断点续传。按既有安全本地下载约定无需交互确认。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "type", Type: shortcut.FlagString, Default: "mediaId", Desc: "资源类型", Enum: []string{"mediaId", "fileId"}},
|
||||
{Name: "type", Type: shortcut.FlagString, Default: "mediaId", Desc: "资源类型;--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id;fileId 不需要消息上下文", Enum: []string{"mediaId", "fileId"}},
|
||||
{Name: "resource-id", Type: shortcut.FlagString, Desc: "消息中的 mediaId 或 fileId", Required: true},
|
||||
{Name: "message-id", Type: shortcut.FlagString, Desc: "mediaId 所属消息的 openMessageId"},
|
||||
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "mediaId 所属会话的 openConversationId"},
|
||||
{Name: "output", Type: shortcut.FlagString, Default: ".", Desc: "工作目录内的相对文件或目录路径"},
|
||||
{Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖已存在的目标文件(默认拒绝)"},
|
||||
{Name: "message-id", Type: shortcut.FlagString, Desc: "mediaId 所属消息的 openMessageId;--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id;fileId 不需要消息上下文"},
|
||||
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "mediaId 所属会话的 openConversationId;--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id;fileId 不需要消息上下文"},
|
||||
{Name: "output", Type: shortcut.FlagString, Default: ".", Desc: "工作目录内的相对路径;不允许绝对路径或 .. 逃逸"},
|
||||
{Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖工作目录内已存在的目标文件(默认拒绝)"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{
|
||||
@@ -204,24 +205,35 @@ func validateResourceDownloadOutputFlag(output, flagName string) error {
|
||||
if output == "" {
|
||||
return apperrors.NewValidation(flagName + " 不能为空")
|
||||
}
|
||||
// Reject OS-absolute paths and Unix-rooted forms ("/x") even on Windows,
|
||||
// where filepath.IsAbs("/x") is false but the path is still not a cwd-relative output.
|
||||
if filepath.IsAbs(output) || strings.HasPrefix(filepath.ToSlash(output), "/") {
|
||||
if resourcePathIsAbsolute(output) {
|
||||
return apperrors.NewValidation(flagName + " 只接受工作目录内的相对路径")
|
||||
}
|
||||
clean := filepath.Clean(filepath.FromSlash(output))
|
||||
if resourcePathEscapesBase(clean) {
|
||||
if resourcePathEscapesBase(output) {
|
||||
return apperrors.NewValidation(flagName + " 不允许使用 .. 逃逸工作目录")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resourcePathEscapesBase reports whether rel escapes its base directory.
|
||||
// Rel values may use '/' or the OS separator (for example mocked filepath.Rel
|
||||
// results), so normalize before comparing against "..".
|
||||
func resourcePathEscapesBase(rel string) bool {
|
||||
rel = filepath.Clean(filepath.FromSlash(strings.TrimSpace(rel)))
|
||||
return rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator))
|
||||
func resourcePathIsAbsolute(value string) bool {
|
||||
if filepath.IsAbs(value) {
|
||||
return true
|
||||
}
|
||||
portable := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
|
||||
if pathpkg.IsAbs(portable) {
|
||||
return true
|
||||
}
|
||||
return len(portable) >= 2 &&
|
||||
((portable[0] >= 'a' && portable[0] <= 'z') ||
|
||||
(portable[0] >= 'A' && portable[0] <= 'Z')) &&
|
||||
portable[1] == ':'
|
||||
}
|
||||
|
||||
// resourcePathEscapesBase reports whether a relative path escapes its base.
|
||||
// Normalize both separators so the check remains portable on every host OS.
|
||||
func resourcePathEscapesBase(value string) bool {
|
||||
portable := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
|
||||
clean := pathpkg.Clean(portable)
|
||||
return clean == ".." || strings.HasPrefix(clean, "../")
|
||||
}
|
||||
|
||||
func resourceDownloadInfo(data map[string]any) (string, map[string]string, error) {
|
||||
@@ -600,5 +612,5 @@ func downloadResourceAtomically(
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(MessagesResourceDownload)
|
||||
shortcut.Register(withReviewedChatShortcutContracts(MessagesResourceDownload)...)
|
||||
}
|
||||
|
||||
@@ -599,3 +599,221 @@ func TestCrossPlatformCoverageDownloadResourceCopySuccessWithBuffer(t *testing.T
|
||||
t.Fatalf("copied = %q", copied.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessageExportFailureBoundaries(t *testing.T) {
|
||||
for _, output := range []string{"exports/", "exports/messages.txt"} {
|
||||
if err := ValidateMessageExportOutput(output); err == nil {
|
||||
t.Fatalf("ValidateMessageExportOutput(%q) succeeded", output)
|
||||
}
|
||||
}
|
||||
if _, _, err := WriteMessageExportJSON("not-json.txt", false, map[string]any{}); err == nil {
|
||||
t.Fatal("invalid export path reached write pipeline")
|
||||
}
|
||||
|
||||
baseSetup := func(t *testing.T) string {
|
||||
t.Helper()
|
||||
resetResourceDownloadHooks(t)
|
||||
base := t.TempDir()
|
||||
resourceGetwd = func() (string, error) { return base, nil }
|
||||
resourceAbs = filepath.Abs
|
||||
resourceEvalSymlinks = filepath.EvalSymlinks
|
||||
resourceLstat = os.Lstat
|
||||
resourceRel = filepath.Rel
|
||||
resourceCreateTemp = os.CreateTemp
|
||||
resourceTempSync = (*os.File).Sync
|
||||
resourceTempClose = (*os.File).Close
|
||||
resourceRename = os.Rename
|
||||
resourceLink = os.Link
|
||||
return base
|
||||
}
|
||||
|
||||
t.Run("path setup", func(t *testing.T) {
|
||||
baseSetup(t)
|
||||
resourceGetwd = func() (string, error) { return "", errors.New("getwd") }
|
||||
if _, _, err := WriteMessageExportJSON("out.json", false, map[string]any{}); err == nil {
|
||||
t.Fatal("getwd failure ignored")
|
||||
}
|
||||
})
|
||||
t.Run("absolute path", func(t *testing.T) {
|
||||
baseSetup(t)
|
||||
resourceAbs = func(string) (string, error) { return "", errors.New("abs") }
|
||||
if _, _, err := WriteMessageExportJSON("out.json", false, map[string]any{}); err == nil {
|
||||
t.Fatal("abs failure ignored")
|
||||
}
|
||||
})
|
||||
t.Run("base symlink", func(t *testing.T) {
|
||||
baseSetup(t)
|
||||
resourceEvalSymlinks = func(string) (string, error) { return "", errors.New("eval") }
|
||||
if _, _, err := WriteMessageExportJSON("out.json", false, map[string]any{}); err == nil {
|
||||
t.Fatal("base symlink failure ignored")
|
||||
}
|
||||
})
|
||||
t.Run("parent symlink", func(t *testing.T) {
|
||||
base := baseSetup(t)
|
||||
calls := 0
|
||||
resourceEvalSymlinks = func(path string) (string, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return base, nil
|
||||
}
|
||||
return "", errors.New("parent")
|
||||
}
|
||||
if _, _, err := WriteMessageExportJSON("exports/out.json", false, map[string]any{}); err == nil {
|
||||
t.Fatal("parent symlink failure ignored")
|
||||
}
|
||||
})
|
||||
t.Run("parent creation", func(t *testing.T) {
|
||||
baseSetup(t)
|
||||
resourceLstat = func(string) (os.FileInfo, error) {
|
||||
return os.Stat(filepath.Join(t.TempDir(), "missing-parent"))
|
||||
}
|
||||
resourceMkdir = func(string, os.FileMode) error { return errors.New("mkdir") }
|
||||
if _, _, err := WriteMessageExportJSON("exports/out.json", false, map[string]any{}); err == nil {
|
||||
t.Fatal("parent creation failure ignored")
|
||||
}
|
||||
})
|
||||
t.Run("parent escapes", func(t *testing.T) {
|
||||
base := baseSetup(t)
|
||||
resourceEvalSymlinks = func(path string) (string, error) {
|
||||
if path == base {
|
||||
return base, nil
|
||||
}
|
||||
return filepath.Dir(base), nil
|
||||
}
|
||||
if _, _, err := WriteMessageExportJSON("exports/out.json", false, map[string]any{}); err == nil {
|
||||
t.Fatal("parent escape accepted")
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setup func(t *testing.T, base string)
|
||||
}{
|
||||
{name: "existing symlink", setup: func(t *testing.T, base string) {
|
||||
target := filepath.Join(base, "out.json")
|
||||
if err := os.Symlink("missing", target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
{name: "existing directory", setup: func(t *testing.T, base string) {
|
||||
if err := os.Mkdir(filepath.Join(base, "out.json"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
{name: "existing no clobber", setup: func(t *testing.T, base string) {
|
||||
if err := os.WriteFile(filepath.Join(base, "out.json"), []byte("old"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
{name: "lstat error", setup: func(t *testing.T, _ string) {
|
||||
resourceLstat = func(string) (os.FileInfo, error) { return nil, errors.New("lstat") }
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
base := baseSetup(t)
|
||||
tc.setup(t, base)
|
||||
if _, _, err := WriteMessageExportJSON("out.json", false, map[string]any{}); err == nil {
|
||||
t.Fatal("invalid existing target accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
overwrite bool
|
||||
payload any
|
||||
setup func(t *testing.T, base string)
|
||||
}{
|
||||
{name: "marshal", payload: make(chan int)},
|
||||
{name: "create temp", payload: map[string]any{}, setup: func(t *testing.T, _ string) {
|
||||
resourceCreateTemp = func(string, string) (*os.File, error) { return nil, errors.New("create") }
|
||||
}},
|
||||
{name: "write", payload: map[string]any{}, setup: func(t *testing.T, base string) {
|
||||
resourceCreateTemp = func(string, string) (*os.File, error) {
|
||||
file, err := os.CreateTemp(base, "closed-*")
|
||||
if err == nil {
|
||||
_ = file.Close()
|
||||
}
|
||||
return file, err
|
||||
}
|
||||
}},
|
||||
{name: "sync", payload: map[string]any{}, setup: func(t *testing.T, _ string) {
|
||||
resourceTempSync = func(*os.File) error { return errors.New("sync") }
|
||||
}},
|
||||
{name: "close", payload: map[string]any{}, setup: func(t *testing.T, _ string) {
|
||||
resourceTempClose = func(file *os.File) error {
|
||||
_ = file.Close()
|
||||
return errors.New("close")
|
||||
}
|
||||
}},
|
||||
{name: "rename", overwrite: true, payload: map[string]any{}, setup: func(t *testing.T, base string) {
|
||||
if err := os.WriteFile(filepath.Join(base, "out.json"), []byte("old"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resourceRename = func(string, string) error { return errors.New("rename") }
|
||||
}},
|
||||
{name: "link exists", payload: map[string]any{}, setup: func(t *testing.T, _ string) {
|
||||
resourceLink = func(string, string) error { return os.ErrExist }
|
||||
}},
|
||||
{name: "link", payload: map[string]any{}, setup: func(t *testing.T, _ string) {
|
||||
resourceLink = func(string, string) error { return errors.New("link") }
|
||||
}},
|
||||
{name: "final rel", overwrite: true, payload: map[string]any{}, setup: func(t *testing.T, base string) {
|
||||
if err := os.WriteFile(filepath.Join(base, "out.json"), []byte("old"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls := 0
|
||||
resourceRel = func(from, to string) (string, error) {
|
||||
calls++
|
||||
if calls < 3 {
|
||||
return filepath.Rel(from, to)
|
||||
}
|
||||
return "", errors.New("rel")
|
||||
}
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
base := baseSetup(t)
|
||||
if tc.setup != nil {
|
||||
tc.setup(t, base)
|
||||
}
|
||||
if _, _, err := WriteMessageExportJSON("out.json", tc.overwrite, tc.payload); err == nil {
|
||||
t.Fatal("failure hook was ignored")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessageExportOverwriteReplacesExistingFile(t *testing.T) {
|
||||
resetResourceDownloadHooks(t)
|
||||
base := t.TempDir()
|
||||
resourceGetwd = func() (string, error) { return base, nil }
|
||||
resourceAbs = filepath.Abs
|
||||
resourceEvalSymlinks = filepath.EvalSymlinks
|
||||
resourceLstat = os.Lstat
|
||||
resourceRel = filepath.Rel
|
||||
resourceCreateTemp = os.CreateTemp
|
||||
resourceTempSync = (*os.File).Sync
|
||||
resourceTempClose = (*os.File).Close
|
||||
resourceRename = replaceFileAtomically
|
||||
resourceLink = os.Link
|
||||
|
||||
target := filepath.Join(base, "out.json")
|
||||
if err := os.WriteFile(target, []byte("old\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
relative, size, err := WriteMessageExportJSON(
|
||||
"out.json", true, map[string]any{"value": "new"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const want = "{\n \"value\": \"new\"\n}\n"
|
||||
if relative != "out.json" || size != len(want) || string(data) != want {
|
||||
t.Fatalf("relative=%q size=%d data=%q", relative, size, data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestCrossPlatformCoverageResourceDownloadInfo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveResourceDownloadPath(t *testing.T) {
|
||||
func TestCrossPlatformCoverageResolveResourceDownloadPath(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(base, "downloads"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -122,14 +122,30 @@ func TestResolveResourceDownloadPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateResourceDownloadOutputUsesOwningFlagName(t *testing.T) {
|
||||
func TestCrossPlatformCoverageValidateResourceDownloadOutputUsesOwningFlagName(t *testing.T) {
|
||||
err := validateResourceDownloadOutputFlag("../escape", "--output-dir")
|
||||
if err == nil || !strings.Contains(err.Error(), "--output-dir") {
|
||||
t.Fatalf("error = %v, want --output-dir", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveResourceDownloadPathRejectsSymlinkParentBeforeCreatingOutside(t *testing.T) {
|
||||
func TestCrossPlatformCoverageValidateResourceDownloadOutputRejectsPortableAbsolutePaths(t *testing.T) {
|
||||
for _, output := range []string{
|
||||
"/absolute", `\\absolute`, `C:\\absolute`, "C:/absolute",
|
||||
"C:relative", "c:relative",
|
||||
} {
|
||||
if err := validateResourceDownloadOutput(output); err == nil {
|
||||
t.Errorf("portable absolute output %q unexpectedly accepted", output)
|
||||
}
|
||||
}
|
||||
for _, output := range []string{"../escape", `..\\escape`} {
|
||||
if err := validateResourceDownloadOutput(output); err == nil {
|
||||
t.Errorf("portable parent escape %q unexpectedly accepted", output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveResourceDownloadPathRejectsSymlinkParentBeforeCreatingOutside(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
link := filepath.Join(base, "linked")
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
const reviewedChatShortcutInterfaceReason = "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref."
|
||||
|
||||
// reviewedChatShortcutContractCommands is the exact reviewed migration ledger
|
||||
// for Chat shortcuts that were delivered by the former committed Schema
|
||||
// catalog but did not yet carry #830's typed Contract declaration. Exact names
|
||||
// deliberately prevent a future shortcut from entering Schema by accident.
|
||||
var reviewedChatShortcutContractCommands = map[string]struct{}{
|
||||
"+category-add-conversation": {},
|
||||
"+category-list-conversations": {},
|
||||
"+category-remove-conversation": {},
|
||||
"+chat-add-bot": {},
|
||||
"+chat-audit-join": {},
|
||||
"+chat-create": {},
|
||||
"+chat-get-by-id": {},
|
||||
"+chat-list": {},
|
||||
"+chat-members-get": {},
|
||||
"+chat-mute-member": {},
|
||||
"+chat-quit": {},
|
||||
"+chat-remove-bot": {},
|
||||
"+chat-role-remove": {},
|
||||
"+chat-role-remove-user": {},
|
||||
"+chat-transfer-owner": {},
|
||||
"+chat-update": {},
|
||||
"+chat-update-icon": {},
|
||||
"+chat-update-settings": {},
|
||||
"+conversation-clear-messages": {},
|
||||
"+conversation-clear-red-point": {},
|
||||
"+conversation-hide": {},
|
||||
"+conversation-mark-read": {},
|
||||
"+conversation-mark-unread": {},
|
||||
"+conversation-mute": {},
|
||||
"+conversation-set-top": {},
|
||||
"+feed-group-query-item": {},
|
||||
"+flag-cancel": {},
|
||||
"+flag-create": {},
|
||||
"+flag-list": {},
|
||||
"+messages-add-emoji": {},
|
||||
"+messages-add-text-emotion": {},
|
||||
"+messages-batch-recall-by-bot": {},
|
||||
"+messages-batch-send-by-bot": {},
|
||||
"+messages-combine-forward": {},
|
||||
"+messages-create-text-emotion": {},
|
||||
"+messages-forward": {},
|
||||
"+messages-forward-topic": {},
|
||||
"+messages-list": {},
|
||||
"+messages-recall": {},
|
||||
"+messages-recall-by-bot": {},
|
||||
"+messages-remove-emoji": {},
|
||||
"+messages-remove-text-emotion": {},
|
||||
"+messages-reply": {},
|
||||
"+messages-resource-download": {},
|
||||
"+messages-resource-url": {},
|
||||
"+messages-send-by-bot": {},
|
||||
"+messages-set-pin": {},
|
||||
"+messages-set-top": {},
|
||||
"+messages-unset-pin": {},
|
||||
"+messages-unset-top": {},
|
||||
}
|
||||
|
||||
// withReviewedChatShortcutContracts ports the previously reviewed Chat Schema
|
||||
// records into #830's typed declaration model. Existing explicit Contracts are
|
||||
// preserved. Missing Contracts must be listed in the exact ledger above.
|
||||
func withReviewedChatShortcutContracts(values ...shortcut.Shortcut) []shortcut.Shortcut {
|
||||
out := make([]shortcut.Shortcut, len(values))
|
||||
for i, value := range values {
|
||||
out[i] = value
|
||||
if !value.Contract.Empty() {
|
||||
continue
|
||||
}
|
||||
if _, reviewed := reviewedChatShortcutContractCommands[value.Command]; !reviewed {
|
||||
continue
|
||||
}
|
||||
out[i].Safety = reviewedChatShortcutSafety(value.Risk)
|
||||
out[i].Contract = reviewedChatShortcutContract(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func reviewedChatShortcutSafety(risk shortcut.Risk) contract.SafetySpec {
|
||||
switch risk {
|
||||
case shortcut.RiskWrite:
|
||||
return contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
}
|
||||
case shortcut.RiskHighWrite:
|
||||
return contract.SafetySpec{
|
||||
Effect: "destructive", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
}
|
||||
default:
|
||||
return contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reviewedChatShortcutContract(value shortcut.Shortcut) corecmd.ContractDecl {
|
||||
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(value.Command, "+"), "-", "_")
|
||||
cliPath := value.Service + " " + value.Command
|
||||
aliases := make([]string, 0, len(value.Aliases))
|
||||
for _, alias := range value.Aliases {
|
||||
aliases = append(aliases, value.Service+" "+alias)
|
||||
}
|
||||
description := strings.TrimSpace(value.Intent)
|
||||
if description == "" {
|
||||
description = strings.TrimSpace(value.Description)
|
||||
}
|
||||
examples := append([]string(nil), value.Tips...)
|
||||
if len(examples) > 2 {
|
||||
examples = examples[:2]
|
||||
}
|
||||
return corecmd.ContractDecl{
|
||||
Title: value.Description,
|
||||
Description: description,
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Reason: reviewedChatShortcutInterfaceReason,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: value.Description,
|
||||
UseWhen: []string{description},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: examples,
|
||||
},
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: value.Service,
|
||||
Name: name,
|
||||
CanonicalPath: value.Service + "." + name,
|
||||
CLIPath: cliPath,
|
||||
PrimaryCLIPath: cliPath,
|
||||
Aliases: aliases,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageReviewedChatShortcutContracts(t *testing.T) {
|
||||
base := shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-create",
|
||||
Aliases: []string{"+chat-create-alias"},
|
||||
Description: "创建群聊",
|
||||
Intent: "需要创建群聊时使用",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Tips: []string{
|
||||
"dws chat +chat-create --title demo",
|
||||
"dws chat +chat-create --title demo2",
|
||||
"dws chat +chat-create --title ignored",
|
||||
},
|
||||
}
|
||||
got := withReviewedChatShortcutContracts(base)
|
||||
if len(got) != 1 || got[0].Contract.Empty() {
|
||||
t.Fatalf("reviewed contract = %#v", got)
|
||||
}
|
||||
declared := got[0]
|
||||
if declared.Safety.Effect != "write" || declared.Safety.Confirmation != "user_required" {
|
||||
t.Fatalf("write safety = %#v", declared.Safety)
|
||||
}
|
||||
if declared.Contract.Identity.CanonicalPath != "chat.shortcut_chat_create" ||
|
||||
declared.Contract.Identity.PrimaryCLIPath != "chat +chat-create" ||
|
||||
len(declared.Contract.Identity.Aliases) != 1 ||
|
||||
declared.Contract.Identity.Aliases[0] != "chat +chat-create-alias" {
|
||||
t.Fatalf("identity = %#v", declared.Contract.Identity)
|
||||
}
|
||||
if len(declared.Contract.Selection.Examples) != 2 {
|
||||
t.Fatalf("examples = %#v, want reviewed maximum of two", declared.Contract.Selection.Examples)
|
||||
}
|
||||
|
||||
high := base
|
||||
high.Risk = shortcut.RiskHighWrite
|
||||
if safety := withReviewedChatShortcutContracts(high)[0].Safety; safety.Effect != "destructive" || safety.Risk != "high" {
|
||||
t.Fatalf("high-write safety = %#v", safety)
|
||||
}
|
||||
read := base
|
||||
read.Risk = shortcut.RiskRead
|
||||
read.Intent = ""
|
||||
if declared := withReviewedChatShortcutContracts(read)[0]; declared.Safety.Effect != "read" || declared.Contract.Description != read.Description {
|
||||
t.Fatalf("read/fallback declaration = %#v", declared)
|
||||
}
|
||||
|
||||
unreviewed := base
|
||||
unreviewed.Command = "+future-unreviewed"
|
||||
if declared := withReviewedChatShortcutContracts(unreviewed)[0]; !declared.Contract.Empty() {
|
||||
t.Fatalf("unreviewed future shortcut entered Schema: %#v", declared.Contract)
|
||||
}
|
||||
explicit := base
|
||||
explicit.Contract = corecmd.ContractDecl{Description: "preserve"}
|
||||
if declared := withReviewedChatShortcutContracts(explicit)[0]; declared.Contract.Description != "preserve" {
|
||||
t.Fatalf("explicit contract overwritten: %#v", declared.Contract)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -18,10 +19,18 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
)
|
||||
|
||||
const messagesSendFileUploadTimeout = 10 * time.Minute
|
||||
|
||||
const (
|
||||
messagesSendMaxBotGroups = 100
|
||||
messagesSendMaxGroupFileSize = 1 << 20
|
||||
)
|
||||
|
||||
var messagesSendReadGroupFile = os.ReadFile
|
||||
|
||||
// MessagesSend is the identity-aware common sending entry point. The current
|
||||
// user branch reuses the native message leaf's reviewed file-upload flow and
|
||||
// existing-mediaId image path. Bot and webhook remain text/Markdown-only
|
||||
@@ -30,8 +39,8 @@ var MessagesSend = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+messages-send",
|
||||
Product: "chat",
|
||||
Description: "统一发送文本、Markdown、当前用户文件或已有 mediaId 图片",
|
||||
Intent: "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;--user 传 userId 时包括在 --dry-run 中也会先通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId。bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。",
|
||||
Description: "按身份和目标统一发送消息,Bot 多群返回逐目标 ledger",
|
||||
Intent: "当你需要文件、复杂 @、幂等,或选择 current-user、bot、webhook 身份发送消息时使用;current-user 可直接传稳定 ID,也可用 --user-query/--chat-query 在 CLI 内唯一解析自然目标,dry-run 与真实执行使用同一解析链。Bot 可用 --groups/--groups-file 向最多 100 个稳定群 ID 发送文本或 Markdown,去重后返回 im.batch-write.v1 逐目标 ledger;webhook 目标由 token 所在群决定。文件上传和已有 mediaId 图片仅 current-user 支持,bot/webhook 不支持富媒体。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
@@ -45,15 +54,15 @@ var MessagesSend = shortcut.Shortcut{
|
||||
CLIPath: "chat +messages-send",
|
||||
PrimaryCLIPath: "chat +messages-send",
|
||||
},
|
||||
Description: "统一发送文本、Markdown、当前用户文件或已有 mediaId 图片",
|
||||
Description: "按身份和目标统一发送消息,Bot 多群返回逐目标 ledger",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed composite send adapter: it selects current-user, bot, or webhook transport; current-user additionally supports live-compatible contact search with exact userId matching, mediaId images, and the native init/upload/commit local-file flow.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "统一发送文本、Markdown、当前用户文件或已有 mediaId 图片",
|
||||
UseWhen: []string{"当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;--user 传 userId 时包括在 --dry-run 中也会先通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId。bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。"},
|
||||
AgentSummary: "按身份和目标统一发送消息,Bot 多群返回逐目标 ledger",
|
||||
UseWhen: []string{"当你需要文件、复杂 @、幂等,或选择 current-user、bot、webhook 身份发送消息时使用;current-user 可直接传稳定 ID,也可用 --user-query/--chat-query 在 CLI 内唯一解析自然目标,dry-run 与真实执行使用同一解析链。Bot 可用 --groups/--groups-file 向最多 100 个稳定群 ID 发送文本或 Markdown,去重后返回 im.batch-write.v1 逐目标 ledger;webhook 目标由 token 所在群决定。文件上传和已有 mediaId 图片仅 current-user 支持,bot/webhook 不支持富媒体。"},
|
||||
AvoidWhen: []string{"需要 bot/webhook 发送媒体、卡片或 thread 回复时不要假设等价支持;改用真实存在的专用下层命令,缺少下层能力时停止"},
|
||||
Examples: []string{
|
||||
"dws chat +messages-send --as user --chat-id <openConversationId> --markdown \"## 周报\" --idempotency-key <key>",
|
||||
@@ -66,7 +75,11 @@ var MessagesSend = shortcut.Shortcut{
|
||||
{Name: "as", Type: shortcut.FlagString, Enum: []string{"user", "bot", "webhook"}, Desc: "--identity 的 lark-cli 对齐别名;受发送身份能力矩阵约束"},
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(user/bot 群聊);受发送身份能力矩阵约束"},
|
||||
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的 lark-cli 对齐别名;受发送身份能力矩阵约束"},
|
||||
{Name: "groups", Type: shortcut.FlagStringSlice, Desc: "多个群 openConversationId(仅 bot;受发送身份能力矩阵约束,逐群返回 typed ledger,最多 100 个)"},
|
||||
{Name: "groups-file", Type: shortcut.FlagString, Desc: "工作目录内相对文本文件(仅 bot;受发送身份能力矩阵约束),每行或逗号分隔一个群 openConversationId"},
|
||||
{Name: "chat-query", Type: shortcut.FlagString, Desc: "按群名解析唯一群聊(仅 user 的高级发送场景);受发送身份能力矩阵约束"},
|
||||
{Name: "user", Type: shortcut.FlagString, Desc: "单聊接收者 userId(user;包括 --dry-run 也会先通过通讯录搜索精确匹配 openDingTalkId);受发送身份能力矩阵约束"},
|
||||
{Name: "user-query", Type: shortcut.FlagString, Desc: "按姓名解析唯一 openDingTalkId(仅 user 的高级发送场景);受发送身份能力矩阵约束"},
|
||||
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊接收者 openDingTalkId(user);受发送身份能力矩阵约束"},
|
||||
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 userId(bot);受发送身份能力矩阵约束"},
|
||||
{Name: "open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 openDingTalkId(bot);受发送身份能力矩阵约束"},
|
||||
@@ -99,14 +112,14 @@ var MessagesSend = shortcut.Shortcut{
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"uuid", "idempotency-key"}},
|
||||
{
|
||||
Kind: shortcut.ConstraintCustom,
|
||||
Flags: []string{"identity", "as", "group", "chat-id", "user", "open-dingtalk-id", "users", "open-dingtalk-ids", "robot-code", "webhook-token", "uuid", "idempotency-key"},
|
||||
Description: "目标、凭据和幂等参数受发送身份能力矩阵约束:user 必须指定一个群聊或单聊目标;bot 必须指定 robot-code 和一类目标;webhook 必须指定 webhook-token;幂等键仅 user 支持",
|
||||
Flags: []string{"identity", "as", "group", "chat-id", "groups", "groups-file", "chat-query", "user", "user-query", "open-dingtalk-id", "users", "open-dingtalk-ids", "robot-code", "webhook-token", "uuid", "idempotency-key"},
|
||||
Description: "目标、凭据和幂等参数受发送身份能力矩阵约束:user 必须指定一个群聊或单聊目标;bot 必须指定 robot-code 和一类目标,多群最多 100 个并逐项返回 ledger;webhook 必须指定 webhook-token;幂等键仅 user 支持",
|
||||
},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws chat +messages-send --as user --chat-id <openConversationId> --markdown "## 周报" --idempotency-key <key>`,
|
||||
`dws chat +messages-send --as user --user <userId> --msg-type file --file ./report.pdf --idempotency-key <key>`,
|
||||
`dws chat +messages-send --as bot --robot-code <robotCode> --users userId1,userId2 --text "请提交周报"`,
|
||||
`dws chat +messages-send --as bot --robot-code <robotCode> --groups <openConversationId1>,<openConversationId2> --text "请提交周报"`,
|
||||
},
|
||||
Validate: validateMessagesSend,
|
||||
Execute: executeMessagesSend,
|
||||
@@ -115,7 +128,13 @@ var MessagesSend = shortcut.Shortcut{
|
||||
func validateMessagesSend(rt *shortcut.RuntimeContext) error {
|
||||
identity := messagesSendIdentity(rt)
|
||||
group := rt.StrFirst("chat-id", "group")
|
||||
botGroups, botGroupsErr := messagesSendBotGroups(rt)
|
||||
if botGroupsErr != nil {
|
||||
return botGroupsErr
|
||||
}
|
||||
chatQuery := rt.Str("chat-query")
|
||||
userID := rt.Str("user")
|
||||
userQuery := rt.Str("user-query")
|
||||
openID := rt.Str("open-dingtalk-id")
|
||||
users := uniqueShortcutStrings(rt.StrSlice("users"))
|
||||
openIDs := uniqueShortcutStrings(rt.StrSlice("open-dingtalk-ids"))
|
||||
@@ -134,29 +153,36 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
|
||||
}
|
||||
switch identity {
|
||||
case "user":
|
||||
targetCount := nonEmptyStringCount(group, userID, openID)
|
||||
targetCount := nonEmptyStringCount(group, chatQuery, userID, userQuery, openID)
|
||||
if targetCount != 1 {
|
||||
return apperrors.NewValidation("--identity user 时 --group、--user、--open-dingtalk-id 必须且只能指定一个")
|
||||
return apperrors.NewValidation("--identity user 时 --group/--chat-id、--chat-query、--user、--user-query、--open-dingtalk-id 必须且只能指定一个")
|
||||
}
|
||||
if len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" || rt.Str("webhook-token") != "" {
|
||||
if len(users) > 0 || len(openIDs) > 0 || len(botGroups) > 0 || rt.Str("robot-code") != "" || rt.Str("webhook-token") != "" {
|
||||
return apperrors.NewValidation("--identity user 不接受 bot/webhook 凭据或批量目标")
|
||||
}
|
||||
if len(atUserIDs) > 0 || len(atMobiles) > 0 {
|
||||
return apperrors.NewValidation("--identity user 只接受 --at-open-dingtalk-ids")
|
||||
}
|
||||
if (userID != "" || openID != "") && (len(atOpenIDs) > 0 || rt.Bool("at-all")) {
|
||||
if (userID != "" || userQuery != "" || openID != "") && (len(atOpenIDs) > 0 || rt.Bool("at-all")) {
|
||||
return apperrors.NewValidation("user 单聊不接受 @ 参数;@ 只适用于群聊")
|
||||
}
|
||||
if contentType != "text" && contentType != "markdown" && (len(atOpenIDs) > 0 || rt.Bool("at-all")) {
|
||||
return apperrors.NewValidation("user image/file/audio/video 当前不接受 @ 参数")
|
||||
}
|
||||
case "bot":
|
||||
if chatQuery != "" || userQuery != "" {
|
||||
return apperrors.NewValidation("--identity bot 当前不接受 --chat-query 或 --user-query;请传真实群 ID 或批量用户 ID")
|
||||
}
|
||||
if rt.Str("robot-code") == "" {
|
||||
return apperrors.NewValidation("--identity bot 必须指定 --robot-code")
|
||||
}
|
||||
hasDirect := len(users)+len(openIDs) > 0
|
||||
if (group != "") == hasDirect {
|
||||
return apperrors.NewValidation("--identity bot 时 --group 与批量单聊目标必须且只能指定一类")
|
||||
hasGroup := group != "" || len(botGroups) > 0
|
||||
if hasGroup == hasDirect {
|
||||
return apperrors.NewValidation("--identity bot 时单群/多群与批量单聊目标必须且只能指定一类")
|
||||
}
|
||||
if group != "" && len(botGroups) > 0 {
|
||||
return apperrors.NewValidation("--identity bot 时 --group/--chat-id 与 --groups/--groups-file 不能同时使用")
|
||||
}
|
||||
if userID != "" || openID != "" || rt.Str("webhook-token") != "" {
|
||||
return apperrors.NewValidation("--identity bot 不接受 --user、--open-dingtalk-id 或 --webhook-token")
|
||||
@@ -170,14 +196,17 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
|
||||
if messagesSendIdempotencyKey(rt) != "" {
|
||||
return apperrors.NewValidation("--uuid 当前仅 user 身份的下层支持")
|
||||
}
|
||||
if contentType != "text" && contentType != "markdown" {
|
||||
if !messageIdentitySupportsContent(identity, contentType) {
|
||||
return apperrors.NewValidation("--identity bot 当前下层只支持 text/markdown")
|
||||
}
|
||||
case "webhook":
|
||||
if chatQuery != "" || userQuery != "" {
|
||||
return apperrors.NewValidation("--identity webhook 的目标由 token 所在群决定,不接受 --chat-query 或 --user-query")
|
||||
}
|
||||
if rt.Str("webhook-token") == "" {
|
||||
return apperrors.NewValidation("--identity webhook 必须指定 --webhook-token")
|
||||
}
|
||||
if group != "" || userID != "" || openID != "" || len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" {
|
||||
if group != "" || len(botGroups) > 0 || userID != "" || openID != "" || len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" {
|
||||
return apperrors.NewValidation("--identity webhook 的目标由 token 所在群决定,不接受其他目标或 bot Code")
|
||||
}
|
||||
if len(atOpenIDs) > 0 {
|
||||
@@ -186,7 +215,7 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
|
||||
if messagesSendIdempotencyKey(rt) != "" {
|
||||
return apperrors.NewValidation("--uuid 当前仅 user 身份的下层支持")
|
||||
}
|
||||
if contentType != "text" && contentType != "markdown" {
|
||||
if !messageIdentitySupportsContent(identity, contentType) {
|
||||
return apperrors.NewValidation("--identity webhook 当前下层只支持 text/markdown")
|
||||
}
|
||||
}
|
||||
@@ -225,33 +254,10 @@ func executeMessagesSend(rt *shortcut.RuntimeContext) error {
|
||||
if contentType == "file" || contentType == "audio" || contentType == "video" {
|
||||
return executeMessagesSendUserFile(rt, group, openID, contentType)
|
||||
}
|
||||
if group != "" {
|
||||
body = helpers.NormalizeMessageMentions(
|
||||
body,
|
||||
uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")),
|
||||
rt.Bool("at-all"),
|
||||
true,
|
||||
)
|
||||
}
|
||||
content, _ := json.Marshal(map[string]string{"title": title, "text": body})
|
||||
params := rt.AddAIMessageTag(map[string]any{
|
||||
"msgType": "markdown",
|
||||
"content": string(content),
|
||||
})
|
||||
if group != "" {
|
||||
params["openConversationId"] = group
|
||||
if values := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")); len(values) > 0 {
|
||||
params["atOpenDingTalkIds"] = values
|
||||
}
|
||||
if rt.Bool("at-all") {
|
||||
params["atAll"] = true
|
||||
}
|
||||
} else {
|
||||
params["receiverOpenDingTalkId"] = openID
|
||||
}
|
||||
if value := messagesSendIdempotencyKey(rt); value != "" {
|
||||
params["uuid"] = value
|
||||
}
|
||||
params := resolvedUserMarkdownParams(rt, ResolvedUserMessageTarget{
|
||||
GroupID: group,
|
||||
OpenDingTalkID: openID,
|
||||
}, title, body, uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")), rt.Bool("at-all"), messagesSendIdempotencyKey(rt))
|
||||
return executeUnifiedMessageWrite(rt, "chat", "send_personal_message", params)
|
||||
case "bot":
|
||||
body = helpers.NormalizeMessageMentions(
|
||||
@@ -281,6 +287,31 @@ func executeMessagesSend(rt *shortcut.RuntimeContext) error {
|
||||
}
|
||||
return executeUnifiedMessageWrite(rt, "bot", "send_robot_group_message", params)
|
||||
}
|
||||
groups, err := messagesSendBotGroups(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(groups) > 0 {
|
||||
items := make([]shortcutBatchWrite, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
arguments := make(map[string]any, len(params)+1)
|
||||
for key, value := range params {
|
||||
arguments[key] = value
|
||||
}
|
||||
arguments["openConversationId"] = group
|
||||
if values := uniqueShortcutStrings(rt.StrSlice("at-user-ids")); len(values) > 0 {
|
||||
arguments["atUserIds"] = values
|
||||
}
|
||||
if values := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")); len(values) > 0 {
|
||||
arguments["atOpendingtalkIds"] = values
|
||||
}
|
||||
if rt.Bool("at-all") {
|
||||
arguments["isAtAll"] = "true"
|
||||
}
|
||||
items = append(items, shortcutBatchWrite{target: group, arguments: arguments})
|
||||
}
|
||||
return executeShortcutBatchWrite(rt, "bot", "send_robot_group_message", items)
|
||||
}
|
||||
if values := uniqueShortcutStrings(rt.StrSlice("users")); len(values) > 0 {
|
||||
params["userIds"] = values
|
||||
}
|
||||
@@ -393,6 +424,20 @@ func messagesSendUserTarget(rt *shortcut.RuntimeContext) (group, openID string,
|
||||
if openID != "" || group != "" {
|
||||
return group, openID, nil
|
||||
}
|
||||
if query := rt.Str("chat-query"); query != "" {
|
||||
resolved, resolveErr := targetresolver.ResolveChat(rt, query)
|
||||
if resolveErr != nil {
|
||||
return "", "", resolveErr
|
||||
}
|
||||
return resolved.Selected.OpenConversationID, "", nil
|
||||
}
|
||||
if query := rt.Str("user-query"); query != "" {
|
||||
resolved, resolveErr := targetresolver.ResolveUser(rt, query, targetresolver.IdentityOpenDingTalkID)
|
||||
if resolveErr != nil {
|
||||
return "", "", resolveErr
|
||||
}
|
||||
return "", resolved.Selected.OpenDingTalkID, nil
|
||||
}
|
||||
openID, err = resolveUserOpenDingTalkID(rt, rt.Str("user"))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
@@ -400,6 +445,64 @@ func messagesSendUserTarget(rt *shortcut.RuntimeContext) (group, openID string,
|
||||
return "", openID, nil
|
||||
}
|
||||
|
||||
// ResolvedUserMessageTarget is the stable target accepted by the shared user
|
||||
// send engine after natural-name resolution has completed.
|
||||
type ResolvedUserMessageTarget struct {
|
||||
GroupID string
|
||||
OpenDingTalkID string
|
||||
}
|
||||
|
||||
// ExecuteResolvedUserMarkdown lets narrow semantic shortcuts such as +dm and
|
||||
// +send-to-group reuse the same target/content/AI-tag parameter builder while
|
||||
// preserving their existing raw lower-response output contract.
|
||||
func ExecuteResolvedUserMarkdown(
|
||||
rt *shortcut.RuntimeContext,
|
||||
target ResolvedUserMessageTarget,
|
||||
text string,
|
||||
) error {
|
||||
params := resolvedUserMarkdownParams(
|
||||
rt,
|
||||
target,
|
||||
text,
|
||||
text,
|
||||
nil,
|
||||
false,
|
||||
"",
|
||||
)
|
||||
return rt.CallMCP("send_personal_message", params)
|
||||
}
|
||||
|
||||
func resolvedUserMarkdownParams(
|
||||
rt *shortcut.RuntimeContext,
|
||||
target ResolvedUserMessageTarget,
|
||||
title, body string,
|
||||
atOpenIDs []string,
|
||||
atAll bool,
|
||||
idempotencyKey string,
|
||||
) map[string]any {
|
||||
if target.GroupID != "" {
|
||||
body = helpers.NormalizeMessageMentions(body, atOpenIDs, atAll, true)
|
||||
}
|
||||
content, _ := json.Marshal(map[string]string{"title": title, "text": body})
|
||||
params := rt.AddAIMessageTag(map[string]any{
|
||||
"msgType": "markdown",
|
||||
"content": string(content),
|
||||
})
|
||||
addMessagesSendUserTarget(params, target.GroupID, target.OpenDingTalkID)
|
||||
if target.GroupID != "" {
|
||||
if len(atOpenIDs) > 0 {
|
||||
params["atOpenDingTalkIds"] = atOpenIDs
|
||||
}
|
||||
if atAll {
|
||||
params["atAll"] = true
|
||||
}
|
||||
}
|
||||
if idempotencyKey != "" {
|
||||
params["uuid"] = idempotencyKey
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func resolveUserOpenDingTalkID(rt *shortcut.RuntimeContext, userID string) (string, error) {
|
||||
userID = strings.TrimSpace(userID)
|
||||
data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{
|
||||
@@ -546,6 +649,52 @@ func messagesSendIdempotencyKey(rt *shortcut.RuntimeContext) string {
|
||||
return rt.StrFirst("idempotency-key", "uuid")
|
||||
}
|
||||
|
||||
func messagesSendBotGroups(rt *shortcut.RuntimeContext) ([]string, error) {
|
||||
if rt.Changed("groups") && rt.Changed("groups-file") {
|
||||
return nil, apperrors.NewValidation("--groups 与 --groups-file 不能同时指定")
|
||||
}
|
||||
groups := uniqueShortcutStrings(rt.StrSlice("groups"))
|
||||
if path := rt.Str("groups-file"); path != "" {
|
||||
safePath, err := apperrors.SafeInputPath(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("校验 --groups-file 失败: %w", err)
|
||||
}
|
||||
info, err := os.Stat(safePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 --groups-file 失败: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, apperrors.NewValidation("--groups-file 必须是普通文本文件")
|
||||
}
|
||||
if info.Size() > messagesSendMaxGroupFileSize {
|
||||
return nil, apperrors.NewValidation("--groups-file 不能超过 1 MiB")
|
||||
}
|
||||
raw, err := messagesSendReadGroupFile(safePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 --groups-file 失败: %w", err)
|
||||
}
|
||||
values := make([]string, 0)
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
values = append(values, strings.Split(line, ",")...)
|
||||
}
|
||||
groups = uniqueShortcutStrings(values)
|
||||
}
|
||||
if len(groups) > messagesSendMaxBotGroups {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf(
|
||||
"bot 多群发送最多支持 %d 个群,当前 %d 个",
|
||||
messagesSendMaxBotGroups, len(groups),
|
||||
))
|
||||
}
|
||||
if (rt.Changed("groups") || rt.Changed("groups-file")) && len(groups) == 0 {
|
||||
return nil, apperrors.NewValidation("bot 多群发送至少需要一个 openConversationId")
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func shortcutMessageTitle(text string) string {
|
||||
text = strings.TrimSpace(strings.SplitN(text, "\n", 2)[0])
|
||||
if utf8.RuneCountInString(text) <= 40 {
|
||||
@@ -556,5 +705,5 @@ func shortcutMessageTitle(text string) string {
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(MessagesSend)
|
||||
shortcut.Register(withReviewedChatShortcutContracts(MessagesSend)...)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendResolvesNaturalUserAndChatTargets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
responses map[string]string
|
||||
wantTarget string
|
||||
wantValue string
|
||||
}{
|
||||
{
|
||||
name: "user query",
|
||||
args: []string{
|
||||
"chat", "+messages-send", "--as", "user",
|
||||
"--user-query", "张三", "--text", "你好", "--yes",
|
||||
},
|
||||
responses: map[string]string{
|
||||
"contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"u1","openDingTalkId":"D1"}]}`,
|
||||
},
|
||||
wantTarget: "receiverOpenDingTalkId",
|
||||
wantValue: "D1",
|
||||
},
|
||||
{
|
||||
name: "chat query exact wins",
|
||||
args: []string{
|
||||
"chat", "+messages-send", "--as", "user",
|
||||
"--chat-query", "项目群", "--text", "你好", "--yes",
|
||||
},
|
||||
responses: map[string]string{
|
||||
"im/search_groups": `{"result":[{"title":"项目群-归档","openConversationId":"c2"},{"title":"项目群","openConversationId":"c1"}]}`,
|
||||
},
|
||||
wantTarget: "openConversationId",
|
||||
wantValue: "c1",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: tt.responses}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(tt.args)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 2 {
|
||||
t.Fatalf("calls = %#v, want resolve + send", fake.calls)
|
||||
}
|
||||
send := fake.calls[1]
|
||||
if send.product != "chat" || send.tool != "send_personal_message" {
|
||||
t.Fatalf("send = %#v", send)
|
||||
}
|
||||
if send.args[tt.wantTarget] != tt.wantValue {
|
||||
t.Fatalf("%s = %#v, want %q", tt.wantTarget, send.args[tt.wantTarget], tt.wantValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendNaturalTargetAmbiguityHasNoWrite(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"u1","openDingTalkId":"D1"},{"name":"张三","userId":"u2","openDingTalkId":"D2"}]}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send", "--as", "user",
|
||||
"--user-query", "张三", "--text", "你好", "--yes",
|
||||
})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("ambiguous user unexpectedly sent")
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "search_contact_by_key_word" {
|
||||
t.Fatalf("ambiguous resolution reached write: %#v", fake.calls)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) {
|
||||
t.Fatalf("error type = %T", err)
|
||||
}
|
||||
if typed.Reason != "resolution_ambiguous" || typed.Details["type"] != "resolution" {
|
||||
t.Fatalf("structured error = %#v", typed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendChatQueryResolvesAllPagesBeforeWrite(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
|
||||
"im/search_groups": {
|
||||
`{"result":[{"title":"项目群-归档","openConversationId":"archive"}],"hasMore":true,"nextCursor":"page-2"}`,
|
||||
`{"result":[{"title":"项目群","openConversationId":"active"}],"hasMore":false}`,
|
||||
},
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send", "--as", "user",
|
||||
"--chat-query", "项目群", "--text", "你好", "--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 3 || fake.calls[0].tool != "search_groups" ||
|
||||
fake.calls[1].tool != "search_groups" || fake.calls[2].tool != "send_personal_message" {
|
||||
t.Fatalf("calls = %#v, want two resolution pages then one write", fake.calls)
|
||||
}
|
||||
if fake.calls[1].args["cursor"] != "page-2" || fake.calls[2].args["openConversationId"] != "active" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendIncompleteChatResolutionHasNoWrite(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/search_groups": `{"result":[{"title":"项目群","openConversationId":"c1"}],"hasMore":true}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send", "--as", "user",
|
||||
"--chat-query", "项目群", "--text", "你好", "--yes",
|
||||
})
|
||||
err := root.Execute()
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) || typed.Reason != "resolution_incomplete" {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "search_groups" {
|
||||
t.Fatalf("incomplete resolution reached write: %#v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendDryRunUsesRealNaturalTargetResolution(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/search_groups": `{"result":[{"title":"项目群","openConversationId":"c1"}]}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send", "--as", "user",
|
||||
"--chat-query", "项目群", "--text", "你好", "--dry-run", "--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("dry-run calls = %#v, want one read-only resolution", fake.calls)
|
||||
}
|
||||
if fake.calls[0].product != "im" || fake.calls[0].tool != "search_groups" {
|
||||
t.Fatalf("dry-run resolution = %#v", fake.calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendRejectsNaturalTargetForUnsupportedIdentity(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send", "--as", "bot", "--robot-code", "r",
|
||||
"--chat-query", "项目群", "--text", "你好", "--yes",
|
||||
})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("bot natural target unexpectedly accepted")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("invalid identity reached lower service: %#v", fake.calls)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,98 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MessageListContractVersion identifies the additive, compatibility-preserving
|
||||
// public envelope shared by message list/search/mget/thread projections.
|
||||
const MessageListContractVersion = "im.message-list.v1"
|
||||
|
||||
// MessageResultContract is the reviewed additive contract shared by message
|
||||
// list, search, mget, @me and thread projections. Keep this descriptor small:
|
||||
// Runtime owns the values, while Skill references and policy checks consume a
|
||||
// copy of these field names so prose cannot silently invent another result
|
||||
// shape.
|
||||
type MessageResultContract struct {
|
||||
Version string
|
||||
MessageFields []string
|
||||
EnvelopeFields []string
|
||||
}
|
||||
|
||||
var messageResultContractV1 = MessageResultContract{
|
||||
Version: MessageListContractVersion,
|
||||
MessageFields: []string{
|
||||
"messageId",
|
||||
"conversationId",
|
||||
"threadId",
|
||||
"sender",
|
||||
"senderId",
|
||||
"senderType",
|
||||
"messageType",
|
||||
"text",
|
||||
"createTime",
|
||||
"updateTime",
|
||||
"reactions",
|
||||
"quotedMessage",
|
||||
"forwarded",
|
||||
"resourceRefs",
|
||||
},
|
||||
EnvelopeFields: []string{
|
||||
"contractVersion",
|
||||
"messages",
|
||||
"count",
|
||||
"pagesFetched",
|
||||
"paginationKnown",
|
||||
"complete",
|
||||
"hasMore",
|
||||
"nextPage",
|
||||
"stopReason",
|
||||
"truncatedByPageLimit",
|
||||
"truncatedByResultLimit",
|
||||
"failedCount",
|
||||
"failures",
|
||||
"partial",
|
||||
"resourceDownloads",
|
||||
},
|
||||
}
|
||||
|
||||
// CurrentMessageResultContract returns defensive copies so callers cannot
|
||||
// mutate the process-wide reviewed descriptor.
|
||||
func CurrentMessageResultContract() MessageResultContract {
|
||||
contract := messageResultContractV1
|
||||
contract.MessageFields = append([]string(nil), contract.MessageFields...)
|
||||
contract.EnvelopeFields = append([]string(nil), contract.EnvelopeFields...)
|
||||
return contract
|
||||
}
|
||||
|
||||
// NewMessageListPayload initializes the common result ledger before a caller
|
||||
// adds pagination or resource-download facts.
|
||||
func NewMessageListPayload(messages []map[string]any) map[string]any {
|
||||
if messages == nil {
|
||||
messages = []map[string]any{}
|
||||
}
|
||||
return map[string]any{
|
||||
"contractVersion": MessageListContractVersion,
|
||||
"messages": messages,
|
||||
"count": len(messages),
|
||||
"pagesFetched": 0,
|
||||
"paginationKnown": false,
|
||||
"complete": false,
|
||||
"hasMore": false,
|
||||
"failedCount": 0,
|
||||
"failures": []map[string]any{},
|
||||
"partial": false,
|
||||
}
|
||||
}
|
||||
|
||||
// StableMessageID returns the normalized message identity used for
|
||||
// cross-page deduplication. An empty value means the lower response did not
|
||||
// publish a stable identity; callers must keep that row rather than guessing.
|
||||
func StableMessageID(message map[string]any) string {
|
||||
value := MessageID(message)
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
|
||||
// Sender reads a message's speaker display name, tolerating common sender-name
|
||||
// keys. The message-list responses carry the display name under the bare
|
||||
// "sender" key (verified live), so it is probed first; the remaining aliases and
|
||||
@@ -150,6 +242,88 @@ func MessageType(m map[string]any) any {
|
||||
return firstMessageValue(m, "msgType", "messageType", "message_type", "type")
|
||||
}
|
||||
|
||||
// SenderID preserves the stable sender identity without replacing the legacy
|
||||
// scalar sender display field. Nested sender records and both userId families
|
||||
// are accepted because list/search/mget currently expose different shapes.
|
||||
func SenderID(m map[string]any) any {
|
||||
for _, key := range []string{"sender", "from", "senderUser"} {
|
||||
if nested, ok := m[key].(map[string]any); ok {
|
||||
if value := firstMessageValue(nested,
|
||||
"openDingTalkId", "openDingtalkId", "userId", "senderId", "id"); value != nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return firstMessageValue(m,
|
||||
"senderOpenDingTalkId", "senderOpenDingtalkId", "senderUserId",
|
||||
"senderId", "sender_id", "senderStaffId", "openDingTalkId", "userId")
|
||||
}
|
||||
|
||||
// SenderType returns only an explicitly published lower sender type. It does
|
||||
// not guess that every sender identity is a user because bot/system messages
|
||||
// can share the same generic senderId key.
|
||||
func SenderType(m map[string]any) any {
|
||||
for _, key := range []string{"sender", "from", "senderUser"} {
|
||||
if nested, ok := m[key].(map[string]any); ok {
|
||||
if value := firstMessageValue(nested, "senderType", "type", "entityType"); value != nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return firstMessageValue(m, "senderType", "sender_type", "fromType", "from_type")
|
||||
}
|
||||
|
||||
// ProjectMessageV1 is the single compatibility-preserving core projection for
|
||||
// list, search, mget, @me, and thread readers. Public wrappers may retain
|
||||
// legacy aliases such as time or msgType, but the underlying identity,
|
||||
// context, reaction, quote, forward, and resource semantics come from here.
|
||||
func ProjectMessageV1(m map[string]any, includeReactions bool) map[string]any {
|
||||
row := map[string]any{
|
||||
"sender": Sender(m),
|
||||
"text": Text(m),
|
||||
"createTime": CreateTime(m),
|
||||
}
|
||||
if value := MessageID(m); value != nil {
|
||||
row["messageId"] = value
|
||||
}
|
||||
if value := ConversationID(m); value != nil {
|
||||
row["conversationId"] = value
|
||||
}
|
||||
if value := ThreadID(m); value != nil {
|
||||
row["threadId"] = value
|
||||
}
|
||||
if value := SenderID(m); value != nil {
|
||||
row["senderId"] = value
|
||||
}
|
||||
if value := SenderType(m); value != nil {
|
||||
row["senderType"] = value
|
||||
}
|
||||
if value := MessageType(m); value != nil {
|
||||
row["messageType"] = value
|
||||
}
|
||||
if value := UpdateTime(m); value != nil {
|
||||
row["updateTime"] = value
|
||||
}
|
||||
if includeReactions {
|
||||
if reactions := Reactions(m); len(reactions) > 0 {
|
||||
row["reactions"] = reactions
|
||||
}
|
||||
}
|
||||
if quoted := QuotedMessage(m); len(quoted) > 0 {
|
||||
row["quotedMessage"] = quoted
|
||||
}
|
||||
if resources := ResourcesDeep(m); len(resources) > 0 {
|
||||
row["resourceRefs"] = resources
|
||||
}
|
||||
projectForwarded := func(item map[string]any) map[string]any {
|
||||
return ProjectMessageV1(item, includeReactions)
|
||||
}
|
||||
if forwarded := Forwarded(m, projectForwarded); len(forwarded) > 0 {
|
||||
row["forwarded"] = forwarded
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// QuotedMessage projects one level of quoted/replied-to context. It is
|
||||
// deliberately non-recursive: a reply chain may be arbitrarily deep or even
|
||||
// cyclic after gateway reshaping, while an Agent primarily needs the quoted
|
||||
@@ -605,13 +779,35 @@ func ApplyPagination(payload, data map[string]any) {
|
||||
// message-list contract paginates with the boundary message createTime, so the
|
||||
// resume object uses exactly that accepted parameter.
|
||||
func ApplyMessagePagination(payload, data map[string]any, messages []map[string]any, direction string) {
|
||||
payload["contractVersion"] = MessageListContractVersion
|
||||
payload["pagesFetched"] = 1
|
||||
payload["enrichedCount"] = 0
|
||||
payload["failedCount"] = 0
|
||||
payload["failures"] = []map[string]any{}
|
||||
payload["hasMore"] = false
|
||||
payload["complete"] = false
|
||||
page := Pagination(data)
|
||||
if len(page) == 0 {
|
||||
payload["paginationKnown"] = false
|
||||
payload["failedCount"] = 1
|
||||
payload["failures"] = []map[string]any{{
|
||||
"stage": "pagination",
|
||||
"error": "下层未返回可靠的 hasMore/nextCursor,无法证明结果完整",
|
||||
}}
|
||||
return
|
||||
}
|
||||
if value, ok := page["hasMore"]; ok {
|
||||
payload["hasMore"] = value
|
||||
value, hasMoreKnown := page["hasMore"]
|
||||
if !hasMoreKnown {
|
||||
payload["paginationKnown"] = false
|
||||
payload["failedCount"] = 1
|
||||
payload["failures"] = []map[string]any{{
|
||||
"stage": "pagination",
|
||||
"error": "下层仅返回 cursor、未返回 hasMore,无法证明结果完整",
|
||||
}}
|
||||
return
|
||||
}
|
||||
payload["paginationKnown"] = true
|
||||
payload["hasMore"] = value
|
||||
if value, ok := page["complete"]; ok {
|
||||
payload["complete"] = value
|
||||
}
|
||||
@@ -621,6 +817,11 @@ func ApplyMessagePagination(payload, data map[string]any, messages []map[string]
|
||||
}
|
||||
boundary := CreateTime(messages[len(messages)-1])
|
||||
if boundary == nil {
|
||||
payload["failedCount"] = 1
|
||||
payload["failures"] = []map[string]any{{
|
||||
"stage": "pagination",
|
||||
"error": "下层返回 hasMore=true,但末条消息缺少可继续读取的 createTime",
|
||||
}}
|
||||
return
|
||||
}
|
||||
next := map[string]any{"time": boundary}
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSender(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSender(t *testing.T) {
|
||||
// The display name lives under the bare "sender" key.
|
||||
if got := Sender(map[string]any{"sender": "念晨", "senderOpenDingTalkId": "D1"}); got != "念晨" {
|
||||
t.Fatalf("sender = %v, want 念晨", got)
|
||||
@@ -48,7 +48,37 @@ func TestSender(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanText(t *testing.T) {
|
||||
func TestCrossPlatformCoverageProjectMessageV1PublishesSharedIdentityAndContext(t *testing.T) {
|
||||
row := ProjectMessageV1(map[string]any{
|
||||
"openMessageId": "msg-1",
|
||||
"openConversationId": "cid-1",
|
||||
"openConvThreadId": "thread-1",
|
||||
"sender": map[string]any{
|
||||
"name": "张三",
|
||||
"openDingTalkId": "D1",
|
||||
"senderType": "user",
|
||||
},
|
||||
"msgType": "text",
|
||||
"content": "你好",
|
||||
"createTime": "2026-08-03 10:00:00",
|
||||
}, true)
|
||||
for key, want := range map[string]any{
|
||||
"messageId": "msg-1",
|
||||
"conversationId": "cid-1",
|
||||
"threadId": "thread-1",
|
||||
"sender": "张三",
|
||||
"senderId": "D1",
|
||||
"senderType": "user",
|
||||
"messageType": "text",
|
||||
"text": "你好",
|
||||
} {
|
||||
if row[key] != want {
|
||||
t.Errorf("%s = %#v, want %#v; row=%#v", key, row[key], want, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCleanText(t *testing.T) {
|
||||
// Out-of-office auto-reply: readable body lives in items[].data.text; the
|
||||
// decorative preview/config JSON lines and "empty" placeholder are dropped.
|
||||
autoReply := "* 仅你和对方可见\n" +
|
||||
@@ -98,7 +128,7 @@ func TestCleanText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEncryptedAndMarker(t *testing.T) {
|
||||
func TestCrossPlatformCoverageIsEncryptedAndMarker(t *testing.T) {
|
||||
cipher := "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP\n" +
|
||||
"7oDptklFO+YzDltH+myErV6rkc8URHYykpeSDsMP6kznFa9E320NsIntfY771dx+\n" +
|
||||
"||2||1||196"
|
||||
@@ -121,7 +151,7 @@ func TestIsEncryptedAndMarker(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestText(t *testing.T) {
|
||||
func TestCrossPlatformCoverageText(t *testing.T) {
|
||||
if got := Text(map[string]any{"content": "你好"}); got != "你好" {
|
||||
t.Errorf("Text string = %v", got)
|
||||
}
|
||||
@@ -136,7 +166,7 @@ func TestText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTime(t *testing.T) {
|
||||
func TestCrossPlatformCoverageCreateTime(t *testing.T) {
|
||||
if got := CreateTime(map[string]any{"sendTime": "2026-07-19 13:37:03"}); got != "2026-07-19 13:37:03" {
|
||||
t.Errorf("CreateTime = %v", got)
|
||||
}
|
||||
@@ -145,7 +175,7 @@ func TestCreateTime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableMessageIdentity(t *testing.T) {
|
||||
func TestCrossPlatformCoverageStableMessageIdentity(t *testing.T) {
|
||||
message := map[string]any{
|
||||
"openMessageId": "msg-1",
|
||||
"openConversationId": "cid-1",
|
||||
@@ -166,7 +196,31 @@ func TestStableMessageIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotedMessageIsBoundedAndSemantic(t *testing.T) {
|
||||
func TestCrossPlatformCoverageMessageLedgerNilAndCursorOnlyBoundaries(t *testing.T) {
|
||||
contract := CurrentMessageResultContract()
|
||||
if contract.Version != MessageListContractVersion || len(contract.MessageFields) == 0 || len(contract.EnvelopeFields) == 0 {
|
||||
t.Fatalf("message result contract = %#v", contract)
|
||||
}
|
||||
contract.MessageFields[0] = "mutated"
|
||||
contract.EnvelopeFields[0] = "mutated"
|
||||
second := CurrentMessageResultContract()
|
||||
if second.MessageFields[0] == "mutated" || second.EnvelopeFields[0] == "mutated" {
|
||||
t.Fatal("message result contract leaked mutable storage")
|
||||
}
|
||||
payload := NewMessageListPayload(nil)
|
||||
if payload["count"] != 0 || payload["messages"] == nil {
|
||||
t.Fatalf("nil message ledger = %#v", payload)
|
||||
}
|
||||
if StableMessageID(map[string]any{}) != "" {
|
||||
t.Fatal("missing message identity was fabricated")
|
||||
}
|
||||
ApplyMessagePagination(payload, map[string]any{"result": map[string]any{"nextCursor": "next"}}, nil, "older")
|
||||
if payload["paginationKnown"] != false || payload["failedCount"] != 1 {
|
||||
t.Fatalf("cursor-only pagination = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageQuotedMessageIsBoundedAndSemantic(t *testing.T) {
|
||||
got := QuotedMessage(map[string]any{
|
||||
"quotedMessage": map[string]any{
|
||||
"openMessageId": "quoted-1",
|
||||
@@ -294,7 +348,7 @@ func TestCrossPlatformCoverageResourceBoundaryHelpers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTimeOmitsUneditedEcho(t *testing.T) {
|
||||
func TestCrossPlatformCoverageUpdateTimeOmitsUneditedEcho(t *testing.T) {
|
||||
if got := UpdateTime(map[string]any{
|
||||
"createTime": "2026-07-19 13:37:03",
|
||||
"updateTime": "2026-07-19 13:37:03",
|
||||
@@ -309,7 +363,7 @@ func TestUpdateTimeOmitsUneditedEcho(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReactionsNormalizesEmotionReplyList(t *testing.T) {
|
||||
func TestCrossPlatformCoverageReactionsNormalizesEmotionReplyList(t *testing.T) {
|
||||
got := Reactions(map[string]any{
|
||||
"emotionReplyList": []any{
|
||||
map[string]any{
|
||||
@@ -345,7 +399,7 @@ func TestReactionsNormalizesEmotionReplyList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPaginationReadsNestedEnvelope(t *testing.T) {
|
||||
func TestCrossPlatformCoverageApplyPaginationReadsNestedEnvelope(t *testing.T) {
|
||||
payload := map[string]any{"count": 98}
|
||||
ApplyPagination(payload, map[string]any{
|
||||
"result": map[string]any{
|
||||
@@ -369,7 +423,7 @@ func TestApplyPaginationReadsNestedEnvelope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMessagePaginationUsesExecutableTimeBoundary(t *testing.T) {
|
||||
func TestCrossPlatformCoverageApplyMessagePaginationUsesExecutableTimeBoundary(t *testing.T) {
|
||||
payload := map[string]any{}
|
||||
ApplyMessagePagination(payload, map[string]any{
|
||||
"result": map[string]any{
|
||||
@@ -389,7 +443,21 @@ func TestApplyMessagePaginationUsesExecutableTimeBoundary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcesBuildsActionableDownloadReferences(t *testing.T) {
|
||||
func TestCrossPlatformCoverageApplyMessagePaginationFailsClosedWhenCompletenessIsUnknown(t *testing.T) {
|
||||
payload := map[string]any{}
|
||||
ApplyMessagePagination(payload, map[string]any{"result": map[string]any{"messages": []any{}}}, nil, "older")
|
||||
if payload["contractVersion"] != MessageListContractVersion ||
|
||||
payload["complete"] != false || payload["paginationKnown"] != false ||
|
||||
payload["failedCount"] != 1 {
|
||||
t.Fatalf("unknown pagination contract = %#v", payload)
|
||||
}
|
||||
failures, _ := payload["failures"].([]map[string]any)
|
||||
if len(failures) != 1 || failures[0]["stage"] != "pagination" {
|
||||
t.Fatalf("unknown pagination failures = %#v", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResourcesBuildsActionableDownloadReferences(t *testing.T) {
|
||||
resources := Resources(map[string]any{
|
||||
"openMessageId": "msg-1",
|
||||
"openConversationId": "cid-1",
|
||||
@@ -416,7 +484,7 @@ func TestResourcesBuildsActionableDownloadReferences(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcesReportsMissingDownloadContext(t *testing.T) {
|
||||
func TestCrossPlatformCoverageResourcesReportsMissingDownloadContext(t *testing.T) {
|
||||
resources := Resources(map[string]any{"content": `{"mediaId":"@image-a"}`})
|
||||
if len(resources) != 1 {
|
||||
t.Fatalf("resources = %#v", resources)
|
||||
@@ -431,7 +499,7 @@ func TestResourcesReportsMissingDownloadContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcesTextMediaIDRequiresWordBoundary(t *testing.T) {
|
||||
func TestCrossPlatformCoverageResourcesTextMediaIDRequiresWordBoundary(t *testing.T) {
|
||||
resources := Resources(map[string]any{
|
||||
"openMessageId": "msg-1",
|
||||
"openConversationId": "cid-1",
|
||||
@@ -442,7 +510,7 @@ func TestResourcesTextMediaIDRequiresWordBoundary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwarded(t *testing.T) {
|
||||
func TestCrossPlatformCoverageForwarded(t *testing.T) {
|
||||
var project func(m map[string]any) map[string]any
|
||||
project = func(m map[string]any) map[string]any {
|
||||
row := map[string]any{"text": Text(m)}
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
)
|
||||
|
||||
// AtMe: pull the messages that recently @-mentioned ME across chats in one step.
|
||||
@@ -86,18 +87,37 @@ var AtMe = shortcut.Shortcut{
|
||||
},
|
||||
},
|
||||
Flags: append([]shortcut.Flag{
|
||||
{Name: "days", Type: shortcut.FlagInt, Desc: "回溯天数(可选,默认 7)", Default: "7", Required: false},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 50)", Default: "50"},
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "仅查看指定群;可传 openConversationId 或群名"},
|
||||
{Name: "chat-query", Type: shortcut.FlagString, Desc: "--group 的旧版自然名称入口", Hidden: true},
|
||||
{Name: "group-query", Type: shortcut.FlagString, Desc: "--chat-query 的兼容别名", Hidden: true},
|
||||
{Name: "days", Type: shortcut.FlagInt, Desc: "回溯天数(默认 7);--days 必须在 1-3650 之间", Default: "7", Required: false},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 50);--limit 必须大于 0", Default: "50"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"},
|
||||
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
|
||||
}, chatshortcut.MessageResourceDownloadFlags()...),
|
||||
Constraints: chatshortcut.MessageResourceDownloadConstraints(),
|
||||
Constraints: append([]shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"group", "chat-query", "group-query"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"days"}, Description: "--days 必须在 1-3650 之间"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0"},
|
||||
}, chatshortcut.MessageResourceDownloadConstraints()...),
|
||||
Tips: []string{
|
||||
`dws chat +at-me`,
|
||||
`dws chat +at-me --days 3`,
|
||||
`dws chat +at-me --group "项目群"`,
|
||||
},
|
||||
Validate: chatshortcut.ValidateMessageResourceDownload,
|
||||
Validate: validateAtMe,
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
groupID := ""
|
||||
directTarget := strings.TrimSpace(rt.Str("group"))
|
||||
queryTarget := strings.TrimSpace(rt.StrFirst("chat-query", "group-query"))
|
||||
if directTarget != "" || queryTarget != "" {
|
||||
resolved, err := targetresolver.ResolveChatTarget(rt, directTarget, queryTarget)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groupID = resolved.Selected.OpenConversationID
|
||||
}
|
||||
|
||||
// Step 1 — look-back window [now-Nd, now] in epoch millis. days defaults
|
||||
// to 7; guard against non-positive overrides so the window stays sane.
|
||||
days := rt.Int("days")
|
||||
@@ -111,35 +131,75 @@ var AtMe = shortcut.Shortcut{
|
||||
// Step 2 — search @me messages. startTime/endTime/limit/cursor and the
|
||||
// first-page defaults (limit 50, cursor "0") mirror
|
||||
// helpers.chatMessageListMentionsCmd's search_at_me_message call.
|
||||
data, err := rt.CallMCPData("chat", "search_at_me_message", map[string]any{
|
||||
params := map[string]any{
|
||||
"startTime": startMs,
|
||||
"endTime": endMs,
|
||||
"limit": rt.Int("limit"),
|
||||
"cursor": rt.Str("cursor"),
|
||||
})
|
||||
}
|
||||
if groupID != "" {
|
||||
params["openConversationId"] = groupID
|
||||
}
|
||||
data, err := rt.CallMCPData("chat", "search_at_me_message", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3 — project matched messages; fall back to the raw payload when we
|
||||
// cannot locate a recognisable message list.
|
||||
// Step 3 — always publish the stable list envelope, including for an empty
|
||||
// or newly-shaped response. This keeps common .messages[]/.items[] jq
|
||||
// projections deterministic instead of turning absence into null.
|
||||
items := atMeMessageItems(data)
|
||||
if len(items) == 0 {
|
||||
return rt.Output(data)
|
||||
}
|
||||
results := make([]map[string]any, 0, len(items))
|
||||
for _, m := range items {
|
||||
results = append(results, atMeProjectWithReactions(m, !rt.Bool("no-reactions")))
|
||||
}
|
||||
payload := map[string]any{"messages": results}
|
||||
payload := chatmsg.NewMessageListPayload(results)
|
||||
payload["items"] = atMeCompatibilityItems(results)
|
||||
chatmsg.ApplyPagination(payload, data)
|
||||
if rt.Bool("download-resources") {
|
||||
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, "")
|
||||
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, groupID)
|
||||
}
|
||||
return rt.Output(payload)
|
||||
},
|
||||
}
|
||||
|
||||
func validateAtMe(rt *shortcut.RuntimeContext) error {
|
||||
if err := chatshortcut.ValidateMessageResourceDownload(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
days := rt.Int("days")
|
||||
if days < 1 || days > 3650 {
|
||||
return localChatOptionError("invalid_lookback_window", "+at-me 的 --days 必须在 1-3650 之间", "--days")
|
||||
}
|
||||
if rt.Int("limit") <= 0 {
|
||||
return localChatOptionError("invalid_page_size", "+at-me 的 --limit 必须大于 0", "--limit")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// atMeCompatibilityItems preserves the common list/items projection used by
|
||||
// older Agent snippets while keeping messages as the canonical v1 contract.
|
||||
// Its conversation field is an object so `.items[].conversation.name` is safe.
|
||||
func atMeCompatibilityItems(messages []map[string]any) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
item := make(map[string]any, len(message))
|
||||
for key, value := range message {
|
||||
item[key] = value
|
||||
}
|
||||
conversation := map[string]any{}
|
||||
if name := atMeString(message["conversation"]); name != "" {
|
||||
conversation["name"] = name
|
||||
}
|
||||
if id := atMeString(message["conversationId"]); id != "" {
|
||||
conversation["openConversationId"] = id
|
||||
}
|
||||
item["conversation"] = conversation
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// atMeMessageItems locates the message list inside a search_at_me_message
|
||||
// response, probing common container keys at the top level and nested under
|
||||
// "result". Returns nil when no list is found.
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
|
||||
const testCipher = "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP||2||1||1"
|
||||
|
||||
func TestAtMeProject(t *testing.T) {
|
||||
func TestCrossPlatformCoverageAtMeProject(t *testing.T) {
|
||||
// nested sender object + plain text
|
||||
row := atMeProject(map[string]any{
|
||||
"sender": map[string]any{"name": "念晨"},
|
||||
@@ -87,7 +87,7 @@ func TestAtMeProject(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMsgProject(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSearchMsgProject(t *testing.T) {
|
||||
// nested sender + plain text + messageId
|
||||
row := searchMsgProject(map[string]any{
|
||||
"sender": map[string]any{"nick": "千启"},
|
||||
@@ -129,7 +129,7 @@ func TestSearchMsgProject(t *testing.T) {
|
||||
// TestSenderHelpers exercises the atMe/searchMsg sender key families directly:
|
||||
// a senderName-family key (first probe loop), a flat string under "sender"
|
||||
// (second loop), and the "null" sentinel normalisation.
|
||||
func TestSenderHelpers(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSenderHelpers(t *testing.T) {
|
||||
cases := []struct {
|
||||
fn func(map[string]any) any
|
||||
name string
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"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"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
)
|
||||
|
||||
// Broadcast: send the SAME single-chat message to several people by NAME.
|
||||
@@ -96,14 +97,28 @@ var Broadcast = shortcut.Shortcut{
|
||||
|
||||
// Step 1 — resolve this name to a unique userId. On failure
|
||||
// (unknown / ambiguous) record it and keep going.
|
||||
user, err := resolveOpenDingTalkUser(rt, name)
|
||||
resolved, err := targetresolver.ResolveEnterpriseUser(rt, name, targetresolver.IdentityAny)
|
||||
if err != nil {
|
||||
failed = append(failed, fmt.Sprintf("%s(%s)", name, err.Error()))
|
||||
continue
|
||||
}
|
||||
if user.openDingTalkID == "" {
|
||||
failed = append(failed, fmt.Sprintf("%s(通讯录结果缺少 openDingTalkId)", name))
|
||||
continue
|
||||
user := resolved.Selected
|
||||
targetArgs := map[string]any{}
|
||||
if user.OpenDingTalkID != "" {
|
||||
targetArgs["receiverOpenDingTalkId"] = user.OpenDingTalkID
|
||||
} else {
|
||||
targetArgs["receiverUserId"] = user.UserID
|
||||
}
|
||||
recipient := user.Name
|
||||
if recipient == "" {
|
||||
recipient = name
|
||||
}
|
||||
messageArgs := rt.AddAIMessageTag(map[string]any{
|
||||
"msgType": "markdown",
|
||||
"content": string(content),
|
||||
})
|
||||
for key, value := range targetArgs {
|
||||
messageArgs[key] = value
|
||||
}
|
||||
|
||||
// Step 2 — send the single-chat message to this recipient. Under
|
||||
@@ -111,26 +126,18 @@ var Broadcast = shortcut.Shortcut{
|
||||
// resolved recipient as "would send" and move on.
|
||||
if rt.DryRun() {
|
||||
plans = append(plans, map[string]any{
|
||||
"recipient": user.name,
|
||||
"recipient": recipient,
|
||||
"tool": "send_personal_message",
|
||||
"arguments": rt.AddAIMessageTag(map[string]any{
|
||||
"receiverOpenDingTalkId": user.openDingTalkID,
|
||||
"msgType": "markdown",
|
||||
"content": string(content),
|
||||
}),
|
||||
"arguments": messageArgs,
|
||||
})
|
||||
sent = append(sent, user.name)
|
||||
sent = append(sent, recipient)
|
||||
continue
|
||||
}
|
||||
if _, err := rt.CallMCPWriteData("chat", "send_personal_message", rt.AddAIMessageTag(map[string]any{
|
||||
"receiverOpenDingTalkId": user.openDingTalkID,
|
||||
"msgType": "markdown",
|
||||
"content": string(content),
|
||||
})); err != nil {
|
||||
if _, err := rt.CallMCPWriteData("chat", "send_personal_message", messageArgs); err != nil {
|
||||
failed = append(failed, fmt.Sprintf("%s(发送失败:%s)", name, err.Error()))
|
||||
continue
|
||||
}
|
||||
sent = append(sent, user.name)
|
||||
sent = append(sent, recipient)
|
||||
}
|
||||
|
||||
// Summarize via rt.Output (structured, honours --format/--jq/--fields)
|
||||
|
||||
@@ -14,40 +14,34 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
)
|
||||
|
||||
var dingTalkMessageLocation = time.FixedZone("CST", 8*60*60)
|
||||
|
||||
const (
|
||||
chatMessagesDefaultPageLimit = 50
|
||||
chatMessagesHardPageLimit = 500
|
||||
chatMessagesAllPageSize = 100
|
||||
)
|
||||
|
||||
func formatDingTalkMessageBoundary(now time.Time) string {
|
||||
return now.In(dingTalkMessageLocation).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// ChatMessages: fetch the message list of one conversation (group OR single
|
||||
// chat) and print a clean projected list (speaker / text / time) instead of a
|
||||
// raw dump.
|
||||
//
|
||||
// Steps:
|
||||
// 1. depending on whether --group or --user is given, call either
|
||||
// list_conversation_message_v2 (group; param openconversation_id) or
|
||||
// list_individual_chat_message (single chat; param userId) on the chat
|
||||
// server — tool names and param keys copied verbatim from chat.go's
|
||||
// `dws chat message list` call sites;
|
||||
// 2. defensively unwrap the message list (multiple candidate container keys)
|
||||
// and project each message to {sender, text, createTime} tolerating field
|
||||
// aliases and one level of nesting;
|
||||
// 3. print via rt.Output as {messages, count} so it honours --format/--jq/--fields.
|
||||
//
|
||||
// The default path only reads and reshapes conversation messages;
|
||||
// --download-resources additionally writes resource files locally.
|
||||
// ChatMessages resolves one conversation, projects messages into the shared
|
||||
// typed result contract, and optionally follows bounded continuation pages,
|
||||
// downloads resources, or atomically exports the complete ledger as JSON.
|
||||
//
|
||||
// dws chat +chat-messages --group <openconversation_id> --time "2025-03-01 00:00:00"
|
||||
// dws chat +chat-messages --user <userId> --time "2025-03-01 00:00:00" --limit 50
|
||||
@@ -55,12 +49,12 @@ var ChatMessages = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-messages",
|
||||
Product: "chat",
|
||||
Description: "拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间",
|
||||
Intent: "当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;" +
|
||||
"群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。" +
|
||||
Description: "按会话 ID、群名或用户 ID 读取消息,支持有界全量分页与原子 JSON 导出",
|
||||
Intent: "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;" +
|
||||
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
|
||||
"省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。" +
|
||||
"内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。" +
|
||||
"默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。",
|
||||
"全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
|
||||
"默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
@@ -74,16 +68,20 @@ var ChatMessages = shortcut.Shortcut{
|
||||
CLIPath: "chat +chat-messages",
|
||||
PrimaryCLIPath: "chat +chat-messages",
|
||||
},
|
||||
Description: "拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间",
|
||||
Description: "按会话 ID、群名或用户 ID 读取消息,支持有界全量分页与原子 JSON 导出",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed built-in Shortcut adapter: it routes group or direct-message history reads, projects a stable message shape, and optionally orchestrates safe resource downloads with a failure ledger.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间",
|
||||
UseWhen: []string{"当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"},
|
||||
AvoidWhen: []string{"要跨多个会话按关键词、发送者或消息类型检索时使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget"},
|
||||
AgentSummary: "按会话 ID、群名或用户 ID 读取消息,支持有界全量分页与原子 JSON 导出",
|
||||
UseWhen: []string{"当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;" +
|
||||
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
|
||||
"省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。" +
|
||||
"全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
|
||||
"默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。"},
|
||||
AvoidWhen: []string{"要跨多个会话按关键词、发送者或消息类型检索时使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget"},
|
||||
Examples: []string{
|
||||
"dws chat +chat-messages --group <openConversationId> --direction older",
|
||||
"dws chat +chat-messages --open-dingtalk-id <openDingTalkId> --download-resources --output-dir ./downloads",
|
||||
@@ -91,92 +89,382 @@ var ChatMessages = shortcut.Shortcut{
|
||||
},
|
||||
},
|
||||
Flags: append([]shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群会话 ID(openConversationId),与 --user 互斥"},
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群名称或 openConversationId,与单聊目标互斥"},
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
|
||||
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
|
||||
{Name: "chat-query", Type: shortcut.FlagString, Desc: "--group 的旧版自然名称入口", Hidden: true},
|
||||
{Name: "user", Type: shortcut.FlagString, Desc: "单聊对方的 userId,与 --group 互斥"},
|
||||
{Name: "user-query", Type: shortcut.FlagString, Desc: "按姓名解析唯一 openDingTalkId 的兼容入口", Hidden: true},
|
||||
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊对方的 openDingTalkId,与 --group/--user 互斥"},
|
||||
{Name: "time", Type: shortcut.FlagString, Desc: "时间边界,如 \"2025-03-01 00:00:00\";省略时从当前时间向前读取最近消息"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的消息条数(可选)"},
|
||||
{Name: "time", Type: shortcut.FlagString, Desc: "时间边界,如 \"2025-03-01 00:00:00\";--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD;省略时从当前时间向前读取最近消息"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的消息条数;显式页大小必须大于 0"},
|
||||
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true},
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的兼容别名", Hidden: true},
|
||||
{Name: "direction", Type: shortcut.FlagString, Enum: []string{"newer", "older"}, Desc: "时间方向 newer/older;省略时为 older,从时间边界向前读取"},
|
||||
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 typed nextPage.time 自动读取后续页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-results 仅与 --page-all 一起使用且不能为负数"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "max-results", Type: shortcut.FlagInt, Desc: "--max-results 仅与 --page-all 一起使用且不能为负数;0 表示仅受页数上限约束"},
|
||||
{Name: "output", Shorthand: "o", Type: shortcut.FlagString, Desc: "把完整结构化 ledger 原子写入工作目录内的相对 JSON 文件"},
|
||||
}, chatshortcut.MessageResourceDownloadFlags()...),
|
||||
Constraints: append([]shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id", "user", "open-dingtalk-id"}},
|
||||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id", "open-conversation-id", "chat-query", "user", "user-query", "open-dingtalk-id"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"limit", "size", "page-size"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"time"}, Description: "--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式页大小必须大于 0"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "max-results"}, Description: "--max-results 仅与 --page-all 一起使用且不能为负数"},
|
||||
{
|
||||
Kind: shortcut.ConstraintCustom,
|
||||
Flags: []string{"output", "overwrite"},
|
||||
Description: "--output 必须是工作目录内的相对 JSON 文件;默认不覆盖,--overwrite 仅与 --output 一起使用",
|
||||
},
|
||||
}, chatshortcut.MessageResourceDownloadConstraints()...),
|
||||
Tips: []string{
|
||||
`dws chat +chat-messages --group <openconversation_id> --time "2025-03-01 00:00:00"`,
|
||||
`dws chat +chat-messages --user <userId> --time "2025-03-01 00:00:00" --limit 50`,
|
||||
`dws chat +chat-messages --group <openconversation_id> --direction older`,
|
||||
`dws chat +chat-messages --user <userId> --time "2025-03-01 00:00:00" --page-all --page-limit 50`,
|
||||
`dws chat +chat-messages --group <openconversation_id> --direction older --page-all --output ./exports/messages.json`,
|
||||
},
|
||||
Validate: chatshortcut.ValidateMessageResourceDownload,
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
// Step 1 — build params and pick the right tool. Param keys
|
||||
// (openconversation_id / userId / time / forward / limit) match the MCP server
|
||||
// schema for group and direct message listing.
|
||||
var tool string
|
||||
params := map[string]any{}
|
||||
fallbackConversationID := ""
|
||||
Validate: validateChatMessages,
|
||||
Execute: executeChatMessages,
|
||||
}
|
||||
|
||||
if rt.Changed("time") && rt.Str("time") != "" {
|
||||
params["time"] = rt.Str("time")
|
||||
} else {
|
||||
params["time"] = formatDingTalkMessageBoundary(time.Now())
|
||||
func validateChatMessages(rt *shortcut.RuntimeContext) error {
|
||||
if err := chatshortcut.ValidateMessageResourceDownload(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
if rt.Changed("time") && strings.TrimSpace(rt.Str("time")) != "" && !validChatTime(rt.Str("time")) {
|
||||
return localChatOptionError("invalid_time_boundary", "+chat-messages 的 --time 格式无效", "--time")
|
||||
}
|
||||
for _, name := range []string{"limit", "size", "page-size"} {
|
||||
if rt.Changed(name) && rt.Int(name) <= 0 {
|
||||
return localChatOptionError("invalid_page_size", "+chat-messages 的 --"+name+" 必须大于 0", "--"+name)
|
||||
}
|
||||
if limit := rt.IntFirst("limit", "size"); limit > 0 {
|
||||
params["limit"] = limit
|
||||
}
|
||||
if !rt.Bool("page-all") && (rt.Changed("page-limit") || rt.Changed("max-results")) {
|
||||
return apperrors.NewValidation("--page-limit/--max-results 仅与 --page-all 一起使用")
|
||||
}
|
||||
if rt.Bool("page-all") {
|
||||
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > chatMessagesHardPageLimit {
|
||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||
}
|
||||
// direction newer/older maps to the tools' boolean `forward` param
|
||||
// (newer -> forward=true, older -> forward=false), matching chat.go's
|
||||
// resolveMessageForward.
|
||||
if rt.Changed("direction") {
|
||||
switch strings.TrimSpace(strings.ToLower(rt.Str("direction"))) {
|
||||
case "newer":
|
||||
params["forward"] = true
|
||||
case "older":
|
||||
params["forward"] = false
|
||||
}
|
||||
} else {
|
||||
params["forward"] = false
|
||||
if rt.Int("max-results") < 0 {
|
||||
return apperrors.NewValidation("--max-results 不能小于 0")
|
||||
}
|
||||
|
||||
if group := rt.StrFirst("group", "conversation-id", "id"); group != "" {
|
||||
tool = "list_conversation_message_v2"
|
||||
params["openconversation_id"] = group
|
||||
fallbackConversationID = group
|
||||
} else if openID := strings.TrimSpace(rt.Str("open-dingtalk-id")); openID != "" {
|
||||
tool = "list_individual_chat_message"
|
||||
params["openDingTalkId"] = openID
|
||||
} else {
|
||||
tool = "list_individual_chat_message"
|
||||
params["userId"] = rt.Str("user")
|
||||
}
|
||||
|
||||
data, err := rt.CallMCPData("chat", tool, params)
|
||||
if err != nil {
|
||||
}
|
||||
if rt.Changed("output") {
|
||||
if err := chatshortcut.ValidateMessageExportOutput(rt.Str("output")); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if rt.Bool("overwrite") {
|
||||
return apperrors.NewValidation("--overwrite 仅与 --output 一起使用")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 2 — defensively unwrap and project. Response shape has no
|
||||
// contract, so probe multiple candidate container/field keys.
|
||||
type chatMessagesRequest struct {
|
||||
tool string
|
||||
params map[string]any
|
||||
direction string
|
||||
fallbackConversationID string
|
||||
}
|
||||
|
||||
func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesRequest, error) {
|
||||
groupID := strings.TrimSpace(rt.StrFirst("conversation-id", "id", "open-conversation-id"))
|
||||
userID := rt.Str("user")
|
||||
openID := rt.Str("open-dingtalk-id")
|
||||
if targetresolver.LooksLikeOpenConversationID(openID) {
|
||||
return chatMessagesRequest{}, apperrors.NewValidation(
|
||||
"--open-dingtalk-id 收到的是群 openConversationId;群聊请改用 --group(兼容别名 --chat)",
|
||||
)
|
||||
}
|
||||
if groupID == "" && (rt.Str("group") != "" || rt.Str("chat-query") != "") {
|
||||
resolved, err := targetresolver.ResolveChatTarget(rt, rt.Str("group"), rt.Str("chat-query"))
|
||||
if err != nil {
|
||||
return chatMessagesRequest{}, err
|
||||
}
|
||||
groupID = resolved.Selected.OpenConversationID
|
||||
}
|
||||
if query := rt.Str("user-query"); query != "" {
|
||||
resolved, err := targetresolver.ResolveUser(rt, query, targetresolver.IdentityOpenDingTalkID)
|
||||
if err != nil {
|
||||
return chatMessagesRequest{}, err
|
||||
}
|
||||
openID = resolved.Selected.OpenDingTalkID
|
||||
}
|
||||
|
||||
direction := strings.TrimSpace(strings.ToLower(rt.Str("direction")))
|
||||
if direction == "" {
|
||||
direction = "older"
|
||||
}
|
||||
params := map[string]any{
|
||||
"time": formatDingTalkMessageBoundary(time.Now()),
|
||||
"forward": direction == "newer",
|
||||
}
|
||||
if rt.Changed("time") && rt.Str("time") != "" {
|
||||
params["time"] = rt.Str("time")
|
||||
}
|
||||
if limit := rt.IntFirst("limit", "size", "page-size"); limit > 0 {
|
||||
params["limit"] = limit
|
||||
} else if rt.Bool("page-all") {
|
||||
params["limit"] = chatMessagesAllPageSize
|
||||
}
|
||||
|
||||
request := chatMessagesRequest{params: params, direction: direction}
|
||||
switch {
|
||||
case groupID != "":
|
||||
request.tool = "list_conversation_message_v2"
|
||||
request.params["openconversation_id"] = groupID
|
||||
request.fallbackConversationID = groupID
|
||||
case openID != "":
|
||||
request.tool = "list_individual_chat_message"
|
||||
request.params["openDingTalkId"] = openID
|
||||
default:
|
||||
request.tool = "list_individual_chat_message"
|
||||
request.params["userId"] = userID
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func executeChatMessages(rt *shortcut.RuntimeContext) error {
|
||||
request, err := resolveChatMessagesRequest(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var payload map[string]any
|
||||
var rawItems []map[string]any
|
||||
if rt.Bool("page-all") {
|
||||
payload, rawItems, err = collectAllChatMessages(rt, request)
|
||||
} else {
|
||||
payload, rawItems, err = collectOneChatMessagesPage(rt, request)
|
||||
}
|
||||
if err != nil {
|
||||
// Full-page collection returns its failure ledger together with a
|
||||
// non-zero error. Publish that ledger for diagnosis, but stop before
|
||||
// resource downloads or a requested export can look successful.
|
||||
if payload != nil {
|
||||
if outputErr := rt.Output(payload); outputErr != nil {
|
||||
return outputErr
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
if rt.Bool("download-resources") {
|
||||
chatshortcut.AttachMessageResourceDownloads(
|
||||
payload,
|
||||
chatshortcut.DownloadMessageResources(rt, rawItems, request.fallbackConversationID),
|
||||
)
|
||||
}
|
||||
if rt.Changed("output") {
|
||||
if rt.DryRun() {
|
||||
payload["export"] = map[string]any{
|
||||
"dryRun": true,
|
||||
"format": "json",
|
||||
"localPath": rt.Str("output"),
|
||||
"overwrite": rt.Bool("overwrite"),
|
||||
}
|
||||
} else {
|
||||
path, size, writeErr := chatshortcut.WriteMessageExportJSON(
|
||||
rt.Str("output"), rt.Bool("overwrite"), payload)
|
||||
if writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
payload["export"] = map[string]any{
|
||||
"format": "json",
|
||||
"localPath": path,
|
||||
"sizeBytes": size,
|
||||
}
|
||||
}
|
||||
}
|
||||
return rt.Output(payload)
|
||||
}
|
||||
|
||||
func collectOneChatMessagesPage(rt *shortcut.RuntimeContext, request chatMessagesRequest) (map[string]any, []map[string]any, error) {
|
||||
data, err := rt.CallMCPData("chat", request.tool, request.params)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
items := chatMessageItems(data)
|
||||
results := projectChatMessages(items, !rt.Bool("no-reactions"))
|
||||
payload := chatmsg.NewMessageListPayload(results)
|
||||
chatmsg.ApplyMessagePagination(payload, data, items, request.direction)
|
||||
if payload["complete"] == true {
|
||||
payload["stopReason"] = "source_complete"
|
||||
} else {
|
||||
payload["stopReason"] = "single_page"
|
||||
}
|
||||
return payload, items, nil
|
||||
}
|
||||
|
||||
func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesRequest) (map[string]any, []map[string]any, error) {
|
||||
pageLimit := defaultChatPageLimit(rt.Int("page-limit"), chatMessagesDefaultPageLimit)
|
||||
maxResults := rt.Int("max-results")
|
||||
seenIDs := map[string]bool{}
|
||||
seenBoundaries := map[string]bool{fmt.Sprint(request.params["time"]): true}
|
||||
allItems := make([]map[string]any, 0)
|
||||
failures := make([]map[string]any, 0)
|
||||
pagesFetched := 0
|
||||
paginationKnown := true
|
||||
complete := false
|
||||
hasMore := false
|
||||
stopReason := "source_complete"
|
||||
truncatedByPageLimit := false
|
||||
truncatedByResultLimit := false
|
||||
var nextPage map[string]any
|
||||
|
||||
for pagesFetched < pageLimit {
|
||||
data, err := rt.CallMCPData("chat", request.tool, request.params)
|
||||
if err != nil {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched + 1,
|
||||
"stage": "read",
|
||||
"error": err.Error(),
|
||||
})
|
||||
stopReason = "read_failure"
|
||||
break
|
||||
}
|
||||
pagesFetched++
|
||||
items := chatMessageItems(data)
|
||||
results := make([]map[string]any, 0, len(items))
|
||||
for _, m := range items {
|
||||
results = append(results, projectChatMessageWithReactions(m, !rt.Bool("no-reactions")))
|
||||
keptOnPage := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
stableID := chatmsg.StableMessageID(item)
|
||||
if stableID != "" && seenIDs[stableID] {
|
||||
continue
|
||||
}
|
||||
if stableID != "" {
|
||||
seenIDs[stableID] = true
|
||||
}
|
||||
allItems = append(allItems, item)
|
||||
keptOnPage = append(keptOnPage, item)
|
||||
if maxResults > 0 && len(allItems) >= maxResults {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"messages": results,
|
||||
"count": len(results),
|
||||
page := chatmsg.Pagination(data)
|
||||
pageHasMore, hasMoreKnown := page["hasMore"].(bool)
|
||||
if !hasMoreKnown {
|
||||
paginationKnown = false
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched,
|
||||
"stage": "pagination",
|
||||
"error": "下层未返回可靠的 hasMore,无法证明全量结果完整",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
direction := strings.TrimSpace(strings.ToLower(rt.Str("direction")))
|
||||
chatmsg.ApplyMessagePagination(payload, data, items, direction)
|
||||
if rt.Bool("download-resources") {
|
||||
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, fallbackConversationID)
|
||||
hasMore = pageHasMore
|
||||
|
||||
if maxResults > 0 && len(allItems) >= maxResults {
|
||||
truncatedByResultLimit = pageHasMore || len(keptOnPage) < len(items)
|
||||
if truncatedByResultLimit {
|
||||
hasMore = true
|
||||
stopReason = "result_limit"
|
||||
if len(keptOnPage) > 0 {
|
||||
nextPage = messageNextPage(keptOnPage[len(keptOnPage)-1], request.direction)
|
||||
boundary := strings.TrimSpace(fmt.Sprint(nextPage["time"]))
|
||||
if boundary == "" || boundary == "<nil>" {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched,
|
||||
"stage": "pagination",
|
||||
"error": "达到结果上限但无法生成可靠的 nextPage.time",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
nextPage = nil
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return rt.Output(payload)
|
||||
},
|
||||
if !pageHasMore {
|
||||
complete = true
|
||||
hasMore = false
|
||||
stopReason = "source_complete"
|
||||
break
|
||||
}
|
||||
if len(items) == 0 {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched,
|
||||
"stage": "pagination",
|
||||
"error": "下层返回 hasMore=true 但当前页没有消息",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
nextPage = messageNextPage(items[len(items)-1], request.direction)
|
||||
boundary := strings.TrimSpace(fmt.Sprint(nextPage["time"]))
|
||||
if boundary == "" || boundary == "<nil>" || seenBoundaries[boundary] {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched,
|
||||
"stage": "pagination",
|
||||
"error": "hasMore=true 但 nextPage.time 缺失或停滞",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
seenBoundaries[boundary] = true
|
||||
request.params["time"] = boundary
|
||||
}
|
||||
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
|
||||
truncatedByPageLimit = true
|
||||
stopReason = "page_limit"
|
||||
}
|
||||
|
||||
results := projectChatMessages(allItems, !rt.Bool("no-reactions"))
|
||||
payload := chatmsg.NewMessageListPayload(results)
|
||||
payload["pagesFetched"] = pagesFetched
|
||||
payload["paginationKnown"] = paginationKnown
|
||||
payload["complete"] = complete && len(failures) == 0
|
||||
payload["hasMore"] = hasMore
|
||||
payload["stopReason"] = stopReason
|
||||
payload["truncatedByPageLimit"] = truncatedByPageLimit
|
||||
payload["truncatedByResultLimit"] = truncatedByResultLimit
|
||||
payload["failedCount"] = len(failures)
|
||||
payload["failures"] = failures
|
||||
payload["partial"] = len(failures) > 0 && len(results) > 0
|
||||
if hasMore && nextPage != nil {
|
||||
payload["nextPage"] = nextPage
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
failureStage := "pagination"
|
||||
if stopReason == "read_failure" {
|
||||
failureStage = "read"
|
||||
}
|
||||
return payload, allItems, apperrors.NewAPI(
|
||||
fmt.Sprintf("全量消息读取未完成:%d 页成功,%d 个页面失败", pagesFetched, len(failures)),
|
||||
apperrors.WithOperation("chat/"+request.tool),
|
||||
apperrors.WithReason("chat_messages_incomplete"),
|
||||
apperrors.WithOrigin("mcp_gateway"),
|
||||
apperrors.WithFailureStage(failureStage),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(true),
|
||||
apperrors.WithHint("请根据 failures 和 nextPage 重试;失败 ledger 不会写入 --output 文件"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"pagesFetched": pagesFetched,
|
||||
"failedCount": len(failures),
|
||||
"failures": failures,
|
||||
"partial": len(results) > 0,
|
||||
"stopReason": stopReason,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return payload, allItems, nil
|
||||
}
|
||||
|
||||
func projectChatMessages(items []map[string]any, includeReactions bool) []map[string]any {
|
||||
results := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
results = append(results, projectChatMessageWithReactions(item, includeReactions))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func messageNextPage(message map[string]any, direction string) map[string]any {
|
||||
return map[string]any{
|
||||
"time": chatmsg.CreateTime(message),
|
||||
"direction": direction,
|
||||
}
|
||||
}
|
||||
|
||||
// chatMessageItems defensively unwraps the message list from the response,
|
||||
@@ -219,44 +507,7 @@ func projectChatMessage(m map[string]any) map[string]any {
|
||||
}
|
||||
|
||||
func projectChatMessageWithReactions(m map[string]any, includeReactions bool) map[string]any {
|
||||
row := map[string]any{
|
||||
"sender": chatmsg.Sender(m),
|
||||
"text": chatmsg.Text(m),
|
||||
"createTime": chatmsg.CreateTime(m),
|
||||
}
|
||||
if messageID := chatmsg.MessageID(m); messageID != nil {
|
||||
row["messageId"] = messageID
|
||||
}
|
||||
if conversationID := chatmsg.ConversationID(m); conversationID != nil {
|
||||
row["conversationId"] = conversationID
|
||||
}
|
||||
if threadID := chatmsg.ThreadID(m); threadID != nil {
|
||||
row["threadId"] = threadID
|
||||
}
|
||||
if messageType := chatmsg.MessageType(m); messageType != nil {
|
||||
row["messageType"] = messageType
|
||||
}
|
||||
if updateTime := chatmsg.UpdateTime(m); updateTime != nil {
|
||||
row["updateTime"] = updateTime
|
||||
}
|
||||
if includeReactions {
|
||||
if reactions := chatmsg.Reactions(m); len(reactions) > 0 {
|
||||
row["reactions"] = reactions
|
||||
}
|
||||
}
|
||||
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
|
||||
row["quotedMessage"] = quoted
|
||||
}
|
||||
if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
|
||||
row["resourceRefs"] = resources
|
||||
}
|
||||
projectForwarded := func(item map[string]any) map[string]any {
|
||||
return projectChatMessageWithReactions(item, includeReactions)
|
||||
}
|
||||
if forwarded := chatmsg.Forwarded(m, projectForwarded); len(forwarded) > 0 {
|
||||
row["forwarded"] = forwarded
|
||||
}
|
||||
return row
|
||||
return chatmsg.ProjectMessageV1(m, includeReactions)
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -13,14 +13,25 @@
|
||||
|
||||
package smart
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// TestProjectChatMessageExpandsForwarded guards that a forwarded chat record
|
||||
// ("聊天记录") exposes its nested messages under "forwarded" instead of
|
||||
// collapsing to the lossy top-level "[卡片]" summary, recursing through nested
|
||||
// forwards, and that the string-"null" sender is nulled out. The per-field
|
||||
// behaviour (sender/text/encryption) is covered in the chatmsg package tests.
|
||||
func TestProjectChatMessageExpandsForwarded(t *testing.T) {
|
||||
func TestCrossPlatformCoverageProjectChatMessageExpandsForwarded(t *testing.T) {
|
||||
row := projectChatMessage(map[string]any{
|
||||
"sender": "hugozhu",
|
||||
"openMessageId": "msg-top",
|
||||
@@ -86,3 +97,238 @@ func TestProjectChatMessageExpandsForwarded(t *testing.T) {
|
||||
t.Errorf("no-reactions projection leaked reactions: %#v", withoutReactions)
|
||||
}
|
||||
}
|
||||
|
||||
type chatMessagesPagingCaller struct {
|
||||
responses []string
|
||||
args []map[string]any
|
||||
failAt int
|
||||
}
|
||||
|
||||
type chatMessagesFailWriter struct{}
|
||||
|
||||
func (chatMessagesFailWriter) Write([]byte) (int, error) {
|
||||
return 0, stderrors.New("fixture output failure")
|
||||
}
|
||||
|
||||
func (c *chatMessagesPagingCaller) CallTool(
|
||||
_ context.Context,
|
||||
_, _ string,
|
||||
args map[string]any,
|
||||
) (*edition.ToolResult, error) {
|
||||
copied := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
copied[key] = value
|
||||
}
|
||||
c.args = append(c.args, copied)
|
||||
index := len(c.args) - 1
|
||||
if c.failAt > 0 && len(c.args) == c.failAt {
|
||||
return nil, stderrors.New("fixture read failure")
|
||||
}
|
||||
if index >= len(c.responses) {
|
||||
index = len(c.responses) - 1
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{
|
||||
Type: "text",
|
||||
Text: c.responses[index],
|
||||
}}}, nil
|
||||
}
|
||||
|
||||
func (c *chatMessagesPagingCaller) CallReadTool(
|
||||
ctx context.Context,
|
||||
product, tool string,
|
||||
args map[string]any,
|
||||
) (*edition.ToolResult, error) {
|
||||
return c.CallTool(ctx, product, tool, args)
|
||||
}
|
||||
|
||||
func (*chatMessagesPagingCaller) Format() string { return "json" }
|
||||
func (*chatMessagesPagingCaller) DryRun() bool { return false }
|
||||
func (*chatMessagesPagingCaller) Fields() string { return "" }
|
||||
func (*chatMessagesPagingCaller) JQ() string { return "" }
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesPageAllUsesTypedBoundaryAndDeduplicates(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m2","createTime":"2026-01-02 00:00:00"},{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`,
|
||||
`{"result":{"hasMore":false,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"},{"openMessageId":"m0","createTime":"2025-12-31 00:00:00"}]}}`,
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-messages", "--conversation-id", "cid",
|
||||
"--time", "2026-01-03 00:00:00", "--page-all", "--page-limit", "5",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.args) != 2 || caller.args[1]["time"] != "2026-01-01 00:00:00" {
|
||||
t.Fatalf("pagination calls = %#v", caller.args)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["complete"] != true || payload["hasMore"] != false ||
|
||||
payload["count"] != float64(3) || payload["pagesFetched"] != float64(2) ||
|
||||
payload["stopReason"] != "source_complete" {
|
||||
t.Fatalf("all-page payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesPageAllPublishesBoundedContinuation(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`,
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-messages", "--conversation-id", "cid",
|
||||
"--page-all", "--page-limit", "1",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next, _ := payload["nextPage"].(map[string]any)
|
||||
if payload["complete"] != false || payload["hasMore"] != true ||
|
||||
payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" ||
|
||||
next["time"] != "2026-01-01 00:00:00" {
|
||||
t.Fatalf("bounded payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesPageAllFailsClosedOnStalledBoundary(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1"}]}}`,
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--page-all"})
|
||||
err := root.Execute()
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) || typed.Category != apperrors.CategoryAPI ||
|
||||
typed.Reason != "chat_messages_incomplete" || !typed.Retryable ||
|
||||
typed.ExecutionStarted == nil || !*typed.ExecutionStarted {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["complete"] != false || payload["failedCount"] != float64(1) ||
|
||||
payload["stopReason"] != "pagination_error" {
|
||||
t.Fatalf("stalled payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesFailedPageDoesNotExportPartialLedger(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
caller := &chatMessagesPagingCaller{
|
||||
responses: []string{
|
||||
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`,
|
||||
},
|
||||
failAt: 2,
|
||||
}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-messages", "--conversation-id", "cid", "--page-all",
|
||||
"--output", "exports/partial.json",
|
||||
})
|
||||
err := root.Execute()
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) || typed.Reason != "chat_messages_incomplete" {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
if _, statErr := os.Lstat("exports/partial.json"); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("partial export exists: %v", statErr)
|
||||
}
|
||||
var ledger map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &ledger); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ledger["partial"] != true || ledger["failedCount"] != float64(1) || ledger["count"] != float64(1) {
|
||||
t.Fatalf("failure ledger = %#v", ledger)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesFailureLedgerOutputErrorIsNonZero(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1"}]}}`,
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(chatMessagesFailWriter{})
|
||||
root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--page-all"})
|
||||
if err := root.Execute(); err == nil || err.Error() != "fixture output failure" {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesExportIsAtomicAndNoClobber(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
newCaller := func() *chatMessagesPagingCaller {
|
||||
return &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":false,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`,
|
||||
}}
|
||||
}
|
||||
run := func(overwrite bool) error {
|
||||
helpers.InitDeps(newCaller())
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
args := []string{"chat", "+chat-messages", "--conversation-id", "cid", "--page-all", "--output", "exports/messages.json"}
|
||||
if overwrite {
|
||||
args = append(args, "--overwrite")
|
||||
}
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
if err := run(false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile("exports/messages.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var exported map[string]any
|
||||
if err := json.Unmarshal(raw, &exported); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exported["complete"] != true || exported["count"] != float64(1) {
|
||||
t.Fatalf("exported ledger = %#v", exported)
|
||||
}
|
||||
if err := run(false); err == nil {
|
||||
t.Fatal("existing export was overwritten without --overwrite")
|
||||
}
|
||||
if err := run(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesExportRejectsNonJSONPlaceholder(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
helpers.InitDeps(&chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":false,"messages":[]}}`,
|
||||
}})
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-messages", "--conversation-id", "cid", "--output", "{}",
|
||||
})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("non-JSON placeholder output unexpectedly succeeded")
|
||||
}
|
||||
if _, err := os.Lstat("{}"); !os.IsNotExist(err) {
|
||||
t.Fatalf("placeholder output was created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -39,10 +40,15 @@ type platformCoverageCaller struct {
|
||||
calls []platformCoverageCall
|
||||
dry bool
|
||||
contactSearchResult string
|
||||
aisearchResult string
|
||||
failTool string
|
||||
}
|
||||
|
||||
func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
f.calls = append(f.calls, platformCoverageCall{product: product, tool: tool, args: args})
|
||||
if product+"/"+tool == f.failTool {
|
||||
return nil, errors.New("fixture failure")
|
||||
}
|
||||
text := `{"result":[]}`
|
||||
switch product + "/" + tool {
|
||||
case "contact/search_contact_by_key_word":
|
||||
@@ -50,6 +56,11 @@ func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool strin
|
||||
if text == "" {
|
||||
text = `{"result":[{"userId":"u1","name":"张三","openDingTalkId":"open1"}]}`
|
||||
}
|
||||
case "aisearch/enterprise_person_search":
|
||||
text = f.aisearchResult
|
||||
if text == "" {
|
||||
text = `{"result":[{"userId":"u1","openDingTalkId":"open1","meta":{"name":"张三","nick":"张三"}}]}`
|
||||
}
|
||||
case "contact/get_current_user_profile":
|
||||
text = `{"result":{"userId":"u1"}}`
|
||||
case "im/search_groups":
|
||||
@@ -99,6 +110,92 @@ func newPlatformCoverageRoot() *cobra.Command {
|
||||
return root
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageIMObservedCompatibilityAliasesReachCanonicalInvocation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
wantKey string
|
||||
wantValue any
|
||||
}{
|
||||
{
|
||||
name: "chat messages open conversation id",
|
||||
args: []string{"chat", "+chat-messages", "--open-conversation-id", "cid-placeholder", "--page-size", "5"},
|
||||
wantTool: "list_conversation_message_v2",
|
||||
wantKey: "limit",
|
||||
wantValue: 5,
|
||||
},
|
||||
{
|
||||
name: "search text query",
|
||||
args: []string{"chat", "+search-msg", "--text-query", "评测", "--no-enrich"},
|
||||
wantTool: "search_messages",
|
||||
wantKey: "keyword",
|
||||
wantValue: "评测",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fake := &platformCoverageCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(tt.args)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != tt.wantTool || !reflect.DeepEqual(fake.calls[0].args[tt.wantKey], tt.wantValue) {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageIMObservedCompatibilityAliasesConflictWithCanonicalFlags(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"chat", "+chat-messages", "--limit", "5", "--page-size", "5", "--conversation-id", "cid"},
|
||||
{"chat", "+search-msg", "--query", "评测", "--text", "评测", "--no-enrich"},
|
||||
} {
|
||||
fake := &platformCoverageCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatalf("conflicting aliases unexpectedly succeeded: %#v", args)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("conflicting aliases reached transport: %#v", fake.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatShortcutsRejectInvalidLocalOptions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
argv []string
|
||||
}{
|
||||
{name: "at me non-positive days", argv: []string{"chat", "+at-me", "--days", "0"}},
|
||||
{name: "at me excessive days", argv: []string{"chat", "+at-me", "--days", "3651"}},
|
||||
{name: "at me non-positive limit", argv: []string{"chat", "+at-me", "--limit", "0"}},
|
||||
{name: "chat messages invalid time", argv: []string{"chat", "+chat-messages", "--group", "cid-1", "--time", "not-a-time"}},
|
||||
{name: "chat messages non-positive limit", argv: []string{"chat", "+chat-messages", "--group", "cid-1", "--limit", "0"}},
|
||||
{name: "thread replies invalid time", argv: []string{"chat", "+thread-replies", "--group", "cid-1", "--thread-id", "thread-1", "--time", "not-a-time"}},
|
||||
{name: "thread replies non-positive limit", argv: []string{"chat", "+thread-replies", "--group", "cid-1", "--thread-id", "thread-1", "--limit", "0"}},
|
||||
{name: "unread chats non-positive count", argv: []string{"chat", "+unread-chats", "--count", "0"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fake := &platformCoverageCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(tc.argv)
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatalf("invalid options unexpectedly succeeded: %v", tc.argv)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("invalid options reached transport: %#v", fake.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAIMessageTag(t *testing.T) {
|
||||
t.Setenv(agentproduct.EnvName, "qwenwork")
|
||||
|
||||
@@ -163,12 +260,12 @@ func TestCrossPlatformCoverageBroadcastDryRunPublishesExecutablePlan(t *testing.
|
||||
"--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
t.Fatalf("execute: %v; calls = %#v", err, fake.calls)
|
||||
}
|
||||
if len(fake.calls) != 1 ||
|
||||
fake.calls[0].product != "contact" ||
|
||||
fake.calls[0].tool != "search_contact_by_key_word" {
|
||||
t.Fatalf("dry-run calls = %#v, want one read-only contact lookup", fake.calls)
|
||||
fake.calls[0].product != "aisearch" ||
|
||||
fake.calls[0].tool != "enterprise_person_search" {
|
||||
t.Fatalf("dry-run calls = %#v, want one read-only enterprise-person lookup", fake.calls)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
@@ -194,6 +291,86 @@ func TestCrossPlatformCoverageBroadcastDryRunPublishesExecutablePlan(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBroadcastUsesEnterpriseAliasAndUserIDFallback(t *testing.T) {
|
||||
fake := &platformCoverageCaller{
|
||||
dry: true,
|
||||
aisearchResult: `{"result":[{
|
||||
"userId":"user-alias",
|
||||
"meta":{"name":"柏荣","nick":"大柚"}
|
||||
}]}`,
|
||||
}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+broadcast",
|
||||
"--to", "大柚",
|
||||
"--text", "你好",
|
||||
"--dry-run",
|
||||
"--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute: %v; calls = %#v", err, fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actions, _ := payload["actions"].([]any)
|
||||
action, _ := actions[0].(map[string]any)
|
||||
arguments, _ := action["arguments"].(map[string]any)
|
||||
if arguments["receiverUserId"] != "user-alias" {
|
||||
t.Fatalf("arguments = %#v", arguments)
|
||||
}
|
||||
if _, ok := arguments["receiverOpenDingTalkId"]; ok {
|
||||
t.Fatalf("unexpected open id fallback = %#v", arguments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBroadcastRecipientFallbackAndSendFailure(t *testing.T) {
|
||||
t.Run("missing display name falls back to query", func(t *testing.T) {
|
||||
fake := &platformCoverageCaller{
|
||||
dry: true,
|
||||
aisearchResult: `{"result":[{"userId":"user-no-name"}]}`,
|
||||
}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+broadcast", "--to", "无名用户", "--text", "你好", "--dry-run", "--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actions, _ := payload["actions"].([]any)
|
||||
action, _ := actions[0].(map[string]any)
|
||||
if action["recipient"] != "无名用户" {
|
||||
t.Fatalf("action = %#v", action)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("send failure remains non-zero", func(t *testing.T) {
|
||||
fake := &platformCoverageCaller{failTool: "chat/send_personal_message"}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+broadcast", "--to", "张三", "--text", "你好", "--yes",
|
||||
})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("send failure unexpectedly succeeded")
|
||||
}
|
||||
if len(fake.calls) != 2 || fake.calls[1].tool != "send_personal_message" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -153,6 +154,106 @@ func TestCrossPlatformCoverageChatMembersListOutcomes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMembersListPaginatesUserBucketAndDeduplicates(t *testing.T) {
|
||||
caller := &smartCoverageCaller{responses: map[string][]string{
|
||||
"chat/get_group_members": {
|
||||
`{"result":{"hasMore":true,"nextCursor":"2","list":[{"memberEmpName":"A","openDingtalkId":"D1"}]}}`,
|
||||
`{"result":{"hasMore":false,"list":[{"memberEmpName":"A","openDingtalkId":"D1"},{"memberEmpName":"B","openDingtalkId":"D2"}]}}`,
|
||||
},
|
||||
"bot/list_group_bots": {`{"result":{"bots":[{"robotName":"R","robotCode":"robot"}]}}`},
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+chat-members-list", "--conversation-id", "cid"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.counts["chat/get_group_members"] != 2 {
|
||||
t.Fatalf("member page calls = %#v", caller.counts)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counts, _ := payload["counts"].(map[string]any)
|
||||
buckets, _ := payload["buckets"].(map[string]any)
|
||||
users, _ := buckets["users"].(map[string]any)
|
||||
if payload["contractVersion"] != groupMembersContractVersion || payload["complete"] != true ||
|
||||
counts["users"] != float64(2) || counts["bots"] != float64(1) ||
|
||||
users["pagesFetched"] != float64(2) || users["complete"] != true {
|
||||
t.Fatalf("member buckets = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMembersListPageLimitPublishesContinuation(t *testing.T) {
|
||||
caller := &smartCoverageCaller{responses: map[string][]string{
|
||||
"chat/get_group_members": {
|
||||
`{"result":{"hasMore":true,"nextCursor":"2","list":[{"memberEmpName":"A","openDingtalkId":"D1"}]}}`,
|
||||
},
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-members-list", "--conversation-id", "cid",
|
||||
"--member-types", "user", "--page-limit", "1",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["complete"] != false || payload["hasMore"] != true || payload["nextCursor"] != "2" {
|
||||
t.Fatalf("bounded member payload = %#v", payload)
|
||||
}
|
||||
buckets := payload["buckets"].(map[string]any)
|
||||
users := buckets["users"].(map[string]any)
|
||||
if users["stopReason"] != "page_limit" || users["failedCount"] != float64(0) {
|
||||
t.Fatalf("bounded user bucket = %#v", users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMembersListKeepsEarlierPagesWhenLaterReadFails(t *testing.T) {
|
||||
caller := &smartCoverageCaller{
|
||||
responses: map[string][]string{
|
||||
"chat/get_group_members": {
|
||||
`{"result":{"hasMore":true,"nextCursor":"2","list":[{"memberEmpName":"A","openDingtalkId":"D1"}]}}`,
|
||||
},
|
||||
},
|
||||
failAt: map[string]int{"chat/get_group_members": 2},
|
||||
}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-members-list", "--conversation-id", "cid", "--member-types", "user",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["complete"] != false || payload["partial"] != true ||
|
||||
payload["hasMore"] != true || payload["nextCursor"] != "2" ||
|
||||
payload["failedCount"] != float64(1) {
|
||||
t.Fatalf("partial member payload = %#v", payload)
|
||||
}
|
||||
buckets := payload["buckets"].(map[string]any)
|
||||
users := buckets["users"].(map[string]any)
|
||||
if users["count"] != float64(1) || users["pagesFetched"] != float64(1) ||
|
||||
users["stopReason"] != "read_failure" || users["partial"] != true {
|
||||
t.Fatalf("partial user bucket = %#v", users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMembersGroupResolutionAndProjection(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -274,3 +375,173 @@ func TestCrossPlatformCoverageSearchPaginationFailureModes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmartResolutionAdapters(t *testing.T) {
|
||||
if defaultChatPageLimit(0, 50) != 50 || defaultChatPageLimit(7, 50) != 7 {
|
||||
t.Fatal("default page limit normalization failed")
|
||||
}
|
||||
if err := localChatOptionError("fixture", "fixture"); err == nil {
|
||||
t.Fatal("local validation error unexpectedly nil")
|
||||
}
|
||||
users := []contactUser{
|
||||
{userID: "u1", openDingTalkID: "d1", name: "甲"},
|
||||
{openDingTalkID: "d2", name: "乙"},
|
||||
}
|
||||
if got := usersWithUserID(users); len(got) != 1 || got[0].userID != "u1" {
|
||||
t.Fatalf("usersWithUserID = %#v", got)
|
||||
}
|
||||
extracted := extractUsers(map[string]any{"result": []any{
|
||||
map[string]any{"userId": "u1", "openDingTalkId": "d1", "name": "甲"},
|
||||
}})
|
||||
if len(extracted) != 1 || extracted[0].openDingTalkID != "d1" {
|
||||
t.Fatalf("extractUsers = %#v", extracted)
|
||||
}
|
||||
if extractUsers(map[string]any{}) != nil {
|
||||
t.Fatal("empty extraction should return nil")
|
||||
}
|
||||
labels := userLabels(users)
|
||||
if len(labels) != 2 || !strings.Contains(labels[0], "甲") {
|
||||
t.Fatalf("user labels = %#v", labels)
|
||||
}
|
||||
resolved := targetresolver.User{UserID: "u", OpenDingTalkID: "d", Name: "姓名"}
|
||||
if got := toResolvedUser(fromResolvedUser(resolved)); got != resolved {
|
||||
t.Fatalf("user adapter round trip = %#v", got)
|
||||
}
|
||||
|
||||
groups := extractGroupsForSend(map[string]any{"result": []any{
|
||||
map[string]any{"openConversationId": "cid-1", "title": "项目群"},
|
||||
map[string]any{"openConversationId": "cid-2", "title": "项目群备份"},
|
||||
}})
|
||||
if len(groups) != 2 || groups[0].id != "cid-1" {
|
||||
t.Fatalf("extractGroupsForSend = %#v", groups)
|
||||
}
|
||||
preferred := preferExactGroupMatches(groups, "项目群")
|
||||
if len(preferred) != 1 || preferred[0].id != "cid-1" {
|
||||
t.Fatalf("preferExactGroupMatches = %#v", preferred)
|
||||
}
|
||||
if labels := sendGroupLabels(groups); len(labels) != 2 || !strings.Contains(labels[0], "项目群") {
|
||||
t.Fatalf("group labels = %#v", labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesValidationAndFailureBoundaries(t *testing.T) {
|
||||
invalid := [][]string{
|
||||
{"--group", "cid", "--page-limit", "2"},
|
||||
{"--group", "cid", "--page-all", "--page-limit", "0"},
|
||||
{"--group", "cid", "--page-all", "--page-limit", "501"},
|
||||
{"--group", "cid", "--page-all", "--max-results", "-1"},
|
||||
{"--group", "cid", "--overwrite"},
|
||||
{"--group", "cid", "--output", "/absolute.json"},
|
||||
{"--group", "missing"},
|
||||
{"--user-query", "missing"},
|
||||
}
|
||||
for _, tail := range invalid {
|
||||
helpers.InitDeps(&smartCoverageCaller{})
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(append([]string{"chat", "+chat-messages"}, tail...))
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Errorf("invalid chat messages args succeeded: %v", tail)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
responses []string
|
||||
failAt int
|
||||
args []string
|
||||
wantError bool
|
||||
}{
|
||||
{name: "single read failure", failAt: 1, args: []string{"--group", "cid123456789"}, wantError: true},
|
||||
{name: "all first read failure", failAt: 1, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true},
|
||||
{name: "all later read failure", responses: []string{`{"result":{"messages":[{"openMessageId":"m1","createTime":"2"}],"hasMore":true}}`}, failAt: 2, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true},
|
||||
{name: "missing pagination", responses: []string{`{"result":{"messages":[]}}`}, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true},
|
||||
{name: "empty page with continuation", responses: []string{`{"result":{"messages":[],"hasMore":true}}`}, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true},
|
||||
{name: "result limit without boundary", responses: []string{`{"result":{"messages":[{"openMessageId":"m1"},{"openMessageId":"m2"}],"hasMore":true}}`}, args: []string{"--group", "cid123456789", "--page-all", "--max-results", "1"}, wantError: true},
|
||||
{name: "result limit with boundary", responses: []string{`{"result":{"messages":[{"openMessageId":"m1","createTime":"2"},{"openMessageId":"m2","createTime":"1"}],"hasMore":true}}`}, args: []string{"--group", "cid123456789", "--page-all", "--max-results", "1"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &smartCoverageCaller{
|
||||
responses: map[string][]string{"chat/list_conversation_message_v2": tc.responses},
|
||||
failAt: map[string]int{"chat/list_conversation_message_v2": tc.failAt},
|
||||
}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(append([]string{"chat", "+chat-messages"}, tc.args...))
|
||||
err := root.Execute()
|
||||
if (err != nil) != tc.wantError {
|
||||
t.Fatalf("error = %v, wantError=%v", err, tc.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
caller := &smartCoverageCaller{responses: map[string][]string{
|
||||
"chat/list_conversation_message_v2": {`{"result":{"messages":[],"hasMore":false}}`},
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+chat-messages", "--group", "cid123456789", "--output", "messages.json", "--dry-run"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageGroupMemberAndAtMeFailureBoundaries(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"chat", "+group-members", "--group", "missing"},
|
||||
{"chat", "+group-members", "--group", "cid", "--page-limit", "0"},
|
||||
{"chat", "+chat-members-list", "--conversation-id", "cid", "--page-limit", "501"},
|
||||
{"chat", "+at-me", "--group", "missing"},
|
||||
} {
|
||||
helpers.InitDeps(&smartCoverageCaller{})
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Errorf("invalid args succeeded: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
caller := &smartCoverageCaller{
|
||||
responses: map[string][]string{
|
||||
"im/search_groups": {`{"result":[{"openConversationId":"cid","title":"群"}]}`},
|
||||
"chat/get_group_members": {`{"result":{"hasMore":true,"list":[]}}`},
|
||||
},
|
||||
}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+group-members", "--group", "群"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"chat", "+chat-messages", "--user", "u1"},
|
||||
{"chat", "+unread-chats", "--count", "1"},
|
||||
} {
|
||||
helpers.InitDeps(&smartCoverageCaller{responses: map[string][]string{
|
||||
"chat/list_individual_chat_message": {`{"result":{"messages":[],"hasMore":false}}`},
|
||||
"chat/list_unread_conversation_messages": {`{"result":[]}`},
|
||||
}})
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("args %v: %v", args, err)
|
||||
}
|
||||
}
|
||||
|
||||
helpers.InitDeps(&smartCoverageCaller{})
|
||||
root = newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+search-msg", "--query", "x", "--chat-query", "missing", "--yes"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("missing search chat query unexpectedly resolved")
|
||||
}
|
||||
|
||||
helpers.InitDeps(&smartCoverageCaller{responses: map[string][]string{
|
||||
"contact/search_contact_by_key_word": {`{"result":[{"userId":"u1","name":"甲"}]}`},
|
||||
"im/search_messages": {`{"result":{"messages":[],"hasMore":false}}`},
|
||||
}})
|
||||
root = newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+search-msg", "--query", "x", "--sender-query", "甲", "--yes"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,13 +18,11 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"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"
|
||||
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
)
|
||||
|
||||
// DM: message a person by NAME, no ID juggling.
|
||||
@@ -38,7 +36,7 @@ var DM = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+dm",
|
||||
Product: "chat",
|
||||
Description: "按姓名直接给某人发单聊消息(自动解析 userId)",
|
||||
Description: "按姓名直接给某人发单聊消息(自动解析唯一 openDingTalkId)",
|
||||
Intent: "当你只知道对方姓名、想直接发一条单聊消息而不想先查 userId 时使用;" +
|
||||
"内部先按姓名搜通讯录解析出唯一用户,并用其 openDingTalkId 发送,姓名匹配到多人时会列出候选让你区分。会真实发出消息。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
@@ -54,14 +52,14 @@ var DM = shortcut.Shortcut{
|
||||
CLIPath: "chat +dm",
|
||||
PrimaryCLIPath: "chat +dm",
|
||||
},
|
||||
Description: "按姓名直接给某人发单聊消息(自动解析 userId)",
|
||||
Description: "按姓名直接给某人发单聊消息(自动解析唯一 openDingTalkId)",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按姓名直接给某人发单聊消息(自动解析 userId)",
|
||||
AgentSummary: "按姓名直接给某人发单聊消息(自动解析唯一 openDingTalkId)",
|
||||
UseWhen: []string{"当你只知道对方姓名、想直接发一条单聊消息而不想先查 userId 时使用;内部先按姓名搜通讯录解析出唯一用户,并用其 openDingTalkId 发送,姓名匹配到多人时会列出候选让你区分。会真实发出消息。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws chat +dm --to 张三 --text \"周报发我一下\""},
|
||||
@@ -76,22 +74,21 @@ var DM = shortcut.Shortcut{
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
text := rt.Str("text")
|
||||
|
||||
// Step 1 — resolve the recipient name to a unique userId.
|
||||
user, err := resolveOpenDingTalkUser(rt, rt.Str("to"))
|
||||
resolved, err := targetresolver.ResolveUser(
|
||||
rt,
|
||||
rt.Str("to"),
|
||||
targetresolver.IdentityOpenDingTalkID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user.openDingTalkID == "" {
|
||||
return apperrors.NewValidation("通讯录结果缺少 openDingTalkId,无法发送单聊消息;请改用 chat +messages-send --open-dingtalk-id")
|
||||
}
|
||||
|
||||
// Step 2 — send the single-chat message.
|
||||
content, _ := json.Marshal(map[string]string{"title": text, "text": text})
|
||||
return rt.CallMCP("send_personal_message", rt.AddAIMessageTag(map[string]any{
|
||||
"receiverOpenDingTalkId": user.openDingTalkID,
|
||||
"msgType": "markdown",
|
||||
"content": string(content),
|
||||
}))
|
||||
return chatshortcut.ExecuteResolvedUserMarkdown(
|
||||
rt,
|
||||
chatshortcut.ResolvedUserMessageTarget{
|
||||
OpenDingTalkID: resolved.Selected.OpenDingTalkID,
|
||||
},
|
||||
text,
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,28 @@ func TestCrossPlatformCoverageSafeResourceQueryDownloadsStayReadOnly(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAtMeEmptyResultKeepsMessagesAndItemsIterable(t *testing.T) {
|
||||
caller := &platformCoverageCaller{}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+at-me", "--format", "json"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode output: %v\n%s", err, output.String())
|
||||
}
|
||||
for _, key := range []string{"messages", "items"} {
|
||||
rows, ok := payload[key].([]any)
|
||||
if !ok || len(rows) != 0 {
|
||||
t.Fatalf("%s = %#v, want empty array", key, payload[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessageReadShortcutsPublishResourceDownloadPlans(t *testing.T) {
|
||||
message := `{"openMessageId":"msg","openConversationId":"cid","content":"{\"mediaId\":\"@image\"}","quotedMessage":{"openMessageId":"quoted","content":"{\"fileId\":\"@quoted-file\"}"}}`
|
||||
tests := []struct {
|
||||
@@ -34,7 +56,7 @@ func TestCrossPlatformCoverageMessageReadShortcutsPublishResourceDownloadPlans(t
|
||||
name: "chat messages",
|
||||
tool: "chat/list_conversation_message_v2",
|
||||
response: `{"result":{"messages":[` + message + `]}}`,
|
||||
args: []string{"chat", "+chat-messages", "--group", "cid"},
|
||||
args: []string{"chat", "+chat-messages", "--conversation-id", "cid"},
|
||||
resultKey: "messages",
|
||||
},
|
||||
{
|
||||
@@ -102,7 +124,7 @@ func TestCrossPlatformCoverageMessageReadShortcutsPublishResourceDownloadPlans(t
|
||||
|
||||
func TestCrossPlatformCoverageMessageReadShortcutResourceOutputValidation(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"chat", "+chat-messages", "--group", "cid"},
|
||||
{"chat", "+chat-messages", "--conversation-id", "cid"},
|
||||
{"chat", "+search-msg", "--query", "x", "--no-enrich"},
|
||||
{"chat", "+at-me"},
|
||||
{"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread"},
|
||||
@@ -121,7 +143,7 @@ func TestCrossPlatformCoverageChatMessagesDefaultsToRecentHistory(t *testing.T)
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
before := time.Now().Add(-2 * time.Second)
|
||||
root.SetArgs([]string{"chat", "+chat-messages", "--group", "cid", "--limit", "5"})
|
||||
root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--limit", "5"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -146,7 +168,7 @@ func TestCrossPlatformCoverageChatMessagesDefaultsToRecentHistory(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDingTalkMessageBoundaryDoesNotDependOnProcessTimezone(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFormatDingTalkMessageBoundaryDoesNotDependOnProcessTimezone(t *testing.T) {
|
||||
now := time.Date(2026, time.July, 29, 1, 2, 3, 0, time.UTC)
|
||||
if got := formatDingTalkMessageBoundary(now); got != "2026-07-29 09:02:03" {
|
||||
t.Fatalf("UTC process boundary = %q, want DingTalk UTC+8 wall time", got)
|
||||
@@ -159,7 +181,7 @@ func TestCrossPlatformCoverageChatMessagesPreservesExplicitTime(t *testing.T) {
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-messages",
|
||||
"--group", "cid",
|
||||
"--conversation-id", "cid",
|
||||
"--time", "2026-07-01 12:34:56",
|
||||
"--yes",
|
||||
})
|
||||
|
||||
@@ -23,6 +23,13 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
)
|
||||
|
||||
const (
|
||||
groupMembersDefaultPageLimit = 50
|
||||
groupMembersHardPageLimit = 500
|
||||
groupMembersContractVersion = "im.group-members.v1"
|
||||
)
|
||||
|
||||
// GroupMembers: list a group's members by its NAME, no openConversationId juggling.
|
||||
@@ -41,10 +48,10 @@ var GroupMembers = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+group-members",
|
||||
Product: "chat",
|
||||
Description: "按群名列出群成员(自动搜群解析 openConversationId)",
|
||||
Description: "按群名唯一解析后全量列出用户成员并公开分页完整性",
|
||||
Intent: "当你只知道群的名字、想看看这个群里有哪些成员,而不想先手动查群 ID 时使用;" +
|
||||
"内部先按群名搜索群聊解析出唯一 openConversationId,再拉取该群的成员列表。" +
|
||||
"群名匹配到多个群时会列出候选让你区分、绝不自行假定。只读,不改动任何数据。",
|
||||
"群名匹配到多个群时会列出候选让你区分、绝不自行假定。用户成员会自动翻页、稳定 ID 去重,并公开 complete/hasMore/nextCursor;--page-limit 保证有界。只读,不改动任何数据。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
@@ -58,65 +65,38 @@ var GroupMembers = shortcut.Shortcut{
|
||||
CLIPath: "chat +group-members",
|
||||
PrimaryCLIPath: "chat +group-members",
|
||||
},
|
||||
Description: "按群名列出群成员(自动搜群解析 openConversationId)",
|
||||
Description: "按群名唯一解析后全量列出用户成员并公开分页完整性",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按群名列出群成员(自动搜群解析 openConversationId)",
|
||||
UseWhen: []string{"当你只知道群的名字、想看看这个群里有哪些成员,而不想先手动查群 ID 时使用;内部先按群名搜索群聊解析出唯一 openConversationId,再拉取该群的成员列表。群名匹配到多个群时会列出候选让你区分、绝不自行假定。只读,不改动任何数据。"},
|
||||
AgentSummary: "按群名唯一解析后全量列出用户成员并公开分页完整性",
|
||||
UseWhen: []string{"当你只知道群的名字、想看看这个群里有哪些成员,而不想先手动查群 ID 时使用;内部先按群名搜索群聊解析出唯一 openConversationId,再拉取该群的成员列表。群名匹配到多个群时会列出候选让你区分、绝不自行假定。用户成员会自动翻页、稳定 ID 去重,并公开 complete/hasMore/nextCursor;--page-limit 保证有界。只读,不改动任何数据。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws chat +group-members --group 项目冲刺"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群名称(搜群关键词,用群名里连续的核心词)", Required: true},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "最大用户成员页数;--page-limit 必须在 1-500 之间"},
|
||||
},
|
||||
Tips: []string{`dws chat +group-members --group 项目冲刺`},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须在 1-500 之间"},
|
||||
},
|
||||
Tips: []string{`dws chat +group-members --group 项目冲刺`},
|
||||
Validate: validateGroupMembersPageLimit,
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
groupName := rt.Str("group")
|
||||
|
||||
// Step 1 — search groups by name (keyword) on the im server.
|
||||
data, err := rt.CallMCPData("im", "search_groups", map[string]any{
|
||||
"keyword": groupName,
|
||||
"limit": 10,
|
||||
"cursor": "0",
|
||||
})
|
||||
resolved, err := targetresolver.ResolveChat(rt, rt.Str("group"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groups := preferExactGroupMatches(extractGroupsForSend(data), groupName)
|
||||
switch {
|
||||
case len(groups) == 0:
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"没找到名字匹配 %q 的群;换用群名里连续的核心词再试。", groupName))
|
||||
case len(groups) > 1:
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"%q 匹配到 %d 个群:%s。请用更精确的群名,或直接用 dws chat group members --id <openConversationId> 指定群。",
|
||||
groupName, len(groups), strings.Join(sendGroupLabels(groups), "、")))
|
||||
}
|
||||
|
||||
// Step 2 — list the members of the unique group. The param key
|
||||
// (openconversation_id) is copied verbatim from chat.go's
|
||||
// get_group_members call site. Project the verbose raw member records
|
||||
// (memberAvatarMediaId, memberDingtalkId, …) down to the useful fields.
|
||||
mdata, err := rt.CallMCPData("chat", "get_group_members", map[string]any{
|
||||
"openconversation_id": groups[0].id,
|
||||
})
|
||||
result, err := collectGroupUserMembers(rt, resolved.Selected.OpenConversationID, rt.Int("page-limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
members := groupMemberProject(mdata)
|
||||
if len(members) == 0 {
|
||||
// Unrecognised shape — fall back to the raw payload rather than
|
||||
// hiding data.
|
||||
return rt.Output(mdata)
|
||||
}
|
||||
payload := map[string]any{"count": len(members), "members": members}
|
||||
chatmsg.ApplyPagination(payload, mdata)
|
||||
return rt.Output(payload)
|
||||
return rt.Output(result.payload(resolved.Selected.OpenConversationID))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -127,34 +107,73 @@ var GroupMembers = shortcut.Shortcut{
|
||||
var ChatMembersList = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-members-list",
|
||||
Aliases: []string{"+chat-group-members"},
|
||||
Product: "chat",
|
||||
Description: "列出群成员并把用户与机器人分桶(支持群名语义解析)",
|
||||
Intent: "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;" +
|
||||
"可直接传 --conversation-id,或传 --group 群名让命令先做唯一匹配。" +
|
||||
"--group 可传群名或 openConversationId,也可用 --conversation-id 显式传稳定 ID、用 --chat-query 显式按群名解析。" +
|
||||
"默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;" +
|
||||
"用户桶会保留下层 hasMore/complete,避免把截断列表误判为完整。",
|
||||
"用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat",
|
||||
Name: "shortcut_chat_members_list",
|
||||
CanonicalPath: "chat.shortcut_chat_members_list",
|
||||
CLIPath: "chat +chat-members-list",
|
||||
PrimaryCLIPath: "chat +chat-members-list",
|
||||
Aliases: []string{"chat +chat-group-members"},
|
||||
},
|
||||
Description: "列出群成员并把用户与机器人分桶(支持群名语义解析)",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed composite member adapter: the executable CLI safely resolves a group, paginates and deduplicates user members, lists bots, and publishes per-bucket completeness and failures.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出群成员并把用户与机器人分桶(支持群名语义解析)",
|
||||
UseWhen: []string{"当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;" +
|
||||
"--group 可传群名或 openConversationId,也可用 --conversation-id 显式传稳定 ID、用 --chat-query 显式按群名解析。" +
|
||||
"默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;" +
|
||||
"用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。"},
|
||||
AvoidWhen: []string{"只需要用户成员且已有群名时可使用 +group-members;需要原始单页响应时使用底层原子命令"},
|
||||
Examples: []string{
|
||||
"dws chat +chat-members-list --group \"项目冲刺\"",
|
||||
"dws chat +chat-members-list --conversation-id <openConversationId> --member-types user,bot",
|
||||
},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群名称(与 --conversation-id 二选一)"},
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "群 openConversationId(与 --group 二选一)"},
|
||||
{Name: "member-types", Type: shortcut.FlagStringSlice, Desc: "成员类型:user,bot;不传则同时返回"},
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群名称或 openConversationId"},
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "显式群 openConversationId"},
|
||||
{Name: "chat-query", Type: shortcut.FlagString, Desc: "按群名解析唯一 openConversationId"},
|
||||
{Name: "chat", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
|
||||
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
|
||||
{Name: "member-types", Type: shortcut.FlagStringSlice, Desc: "成员类型;--member-types 仅接受 user/bot;不传则同时返回"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "用户成员桶最大页数;--page-limit 必须在 1-500 之间"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id"}},
|
||||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "chat-query", "chat", "open-conversation-id"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"member-types"}, Description: "--member-types 仅接受 user/bot"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须在 1-500 之间"},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws chat +chat-members-list --group "项目冲刺"`,
|
||||
`dws chat +chat-members-list --conversation-id <openConversationId> --member-types user,bot`,
|
||||
},
|
||||
Validate: validateGroupMembersPageLimit,
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
groupID := strings.TrimSpace(rt.Str("conversation-id"))
|
||||
groupName := strings.TrimSpace(rt.Str("group"))
|
||||
groupID := strings.TrimSpace(rt.StrFirst("conversation-id", "chat", "open-conversation-id"))
|
||||
if groupID == "" {
|
||||
resolved, err := resolveGroupName(rt, groupName)
|
||||
resolved, err := targetresolver.ResolveChatTarget(rt, rt.Str("group"), rt.Str("chat-query"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groupID = resolved
|
||||
groupID = resolved.Selected.OpenConversationID
|
||||
}
|
||||
|
||||
wantUsers, wantBots, err := resolveMemberTypes(rt.StrSlice("member-types"))
|
||||
@@ -163,19 +182,19 @@ var ChatMembersList = shortcut.Shortcut{
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"conversationId": groupID,
|
||||
"users": []map[string]any{},
|
||||
"bots": []map[string]any{},
|
||||
"contractVersion": groupMembersContractVersion,
|
||||
"conversationId": groupID,
|
||||
"users": []map[string]any{},
|
||||
"bots": []map[string]any{},
|
||||
}
|
||||
buckets := map[string]any{}
|
||||
var userErr, botErr error
|
||||
var userResult groupUserMembersResult
|
||||
if wantUsers {
|
||||
var data map[string]any
|
||||
data, userErr = rt.CallMCPData("chat", "get_group_members", map[string]any{
|
||||
"openconversation_id": groupID,
|
||||
})
|
||||
userResult, userErr = collectGroupUserMembers(rt, groupID, rt.Int("page-limit"))
|
||||
if userErr == nil {
|
||||
payload["users"] = groupMemberProject(data)
|
||||
chatmsg.ApplyPagination(payload, data)
|
||||
payload["users"] = userResult.members
|
||||
buckets["users"] = userResult.bucketPayload()
|
||||
}
|
||||
}
|
||||
if wantBots {
|
||||
@@ -184,7 +203,12 @@ var ChatMembersList = shortcut.Shortcut{
|
||||
"openConversationId": groupID,
|
||||
})
|
||||
if botErr == nil {
|
||||
payload["bots"] = groupBotProject(data)
|
||||
bots := groupBotProject(data)
|
||||
payload["bots"] = bots
|
||||
buckets["bots"] = map[string]any{
|
||||
"complete": true,
|
||||
"count": len(bots),
|
||||
}
|
||||
}
|
||||
}
|
||||
if wantUsers && userErr != nil && !wantBots {
|
||||
@@ -196,16 +220,19 @@ var ChatMembersList = shortcut.Shortcut{
|
||||
if userErr != nil && botErr != nil {
|
||||
return fmt.Errorf("读取用户成员失败: %v;读取机器人失败: %v", userErr, botErr)
|
||||
}
|
||||
errors := make([]map[string]any, 0, 1)
|
||||
failures := make([]map[string]any, 0, 1+len(userResult.failures))
|
||||
failures = append(failures, userResult.failures...)
|
||||
if userErr != nil {
|
||||
errors = append(errors, map[string]any{"bucket": "users", "error": userErr.Error()})
|
||||
failures = append(failures, map[string]any{"bucket": "users", "stage": "read", "error": userErr.Error()})
|
||||
}
|
||||
if botErr != nil {
|
||||
errors = append(errors, map[string]any{"bucket": "bots", "error": botErr.Error()})
|
||||
failures = append(failures, map[string]any{"bucket": "bots", "stage": "read", "error": botErr.Error()})
|
||||
}
|
||||
if len(errors) > 0 {
|
||||
payload["errors"] = errors
|
||||
if len(failures) > 0 {
|
||||
payload["errors"] = failures
|
||||
payload["partial"] = true
|
||||
} else {
|
||||
payload["partial"] = false
|
||||
}
|
||||
|
||||
users, _ := payload["users"].([]map[string]any)
|
||||
@@ -215,31 +242,134 @@ var ChatMembersList = shortcut.Shortcut{
|
||||
"bots": len(bots),
|
||||
"total": len(users) + len(bots),
|
||||
}
|
||||
payload["buckets"] = buckets
|
||||
payload["failedCount"] = len(failures)
|
||||
payload["failures"] = failures
|
||||
payload["complete"] = len(failures) == 0 && (!wantUsers || userResult.complete)
|
||||
payload["hasMore"] = wantUsers && userResult.hasMore
|
||||
if wantUsers && userResult.nextCursor != "" {
|
||||
payload["nextCursor"] = userResult.nextCursor
|
||||
}
|
||||
return rt.Output(payload)
|
||||
},
|
||||
}
|
||||
|
||||
func resolveGroupName(rt *shortcut.RuntimeContext, groupName string) (string, error) {
|
||||
data, err := rt.CallMCPData("im", "search_groups", map[string]any{
|
||||
"keyword": groupName,
|
||||
"limit": 10,
|
||||
"cursor": "0",
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
func validateGroupMembersPageLimit(rt *shortcut.RuntimeContext) error {
|
||||
if limit := rt.Int("page-limit"); limit < 1 || limit > groupMembersHardPageLimit {
|
||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||
}
|
||||
groups := preferExactGroupMatches(extractGroupsForSend(data), groupName)
|
||||
switch {
|
||||
case len(groups) == 0:
|
||||
return "", apperrors.NewValidation(fmt.Sprintf(
|
||||
"没找到名字匹配 %q 的群;换用群名里连续的核心词再试。", groupName))
|
||||
case len(groups) > 1:
|
||||
return "", apperrors.NewValidation(fmt.Sprintf(
|
||||
"%q 匹配到 %d 个群:%s。请用更精确的群名,或传 --conversation-id 精确指定。",
|
||||
groupName, len(groups), strings.Join(sendGroupLabels(groups), "、")))
|
||||
default:
|
||||
return groups[0].id, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
type groupUserMembersResult struct {
|
||||
members []map[string]any
|
||||
pagesFetched int
|
||||
complete bool
|
||||
hasMore bool
|
||||
nextCursor string
|
||||
stopReason string
|
||||
failures []map[string]any
|
||||
}
|
||||
|
||||
func collectGroupUserMembers(rt *shortcut.RuntimeContext, groupID string, pageLimit int) (groupUserMembersResult, error) {
|
||||
pageLimit = defaultChatPageLimit(pageLimit, groupMembersDefaultPageLimit)
|
||||
result := groupUserMembersResult{
|
||||
members: []map[string]any{},
|
||||
failures: []map[string]any{},
|
||||
stopReason: "source_complete",
|
||||
}
|
||||
cursor := "0"
|
||||
seenCursors := map[string]bool{cursor: true}
|
||||
seenIDs := map[string]bool{}
|
||||
for result.pagesFetched < pageLimit {
|
||||
data, err := rt.CallMCPData("chat", "get_group_members", map[string]any{
|
||||
"openconversation_id": groupID,
|
||||
"cursor": cursor,
|
||||
})
|
||||
if err != nil {
|
||||
if result.pagesFetched > 0 {
|
||||
result.failures = append(result.failures, map[string]any{
|
||||
"bucket": "users",
|
||||
"page": result.pagesFetched + 1,
|
||||
"stage": "read",
|
||||
"error": err.Error(),
|
||||
})
|
||||
result.stopReason = "read_failure"
|
||||
return result, nil
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
result.pagesFetched++
|
||||
for _, member := range groupMemberProject(data) {
|
||||
stableID := strings.TrimSpace(fmt.Sprint(member["openDingtalkId"]))
|
||||
if stableID != "" && stableID != "<nil>" && seenIDs[stableID] {
|
||||
continue
|
||||
}
|
||||
if stableID != "" && stableID != "<nil>" {
|
||||
seenIDs[stableID] = true
|
||||
}
|
||||
result.members = append(result.members, member)
|
||||
}
|
||||
page := chatmsg.Pagination(data)
|
||||
hasMore, known := page["hasMore"].(bool)
|
||||
if !known {
|
||||
result.failures = append(result.failures, map[string]any{
|
||||
"bucket": "users",
|
||||
"page": result.pagesFetched,
|
||||
"stage": "pagination",
|
||||
"error": "群成员下层未返回可靠的 hasMore,无法证明用户桶完整",
|
||||
})
|
||||
result.stopReason = "pagination_error"
|
||||
return result, nil
|
||||
}
|
||||
result.hasMore = hasMore
|
||||
if !hasMore {
|
||||
result.complete = true
|
||||
result.stopReason = "source_complete"
|
||||
return result, nil
|
||||
}
|
||||
nextCursor := strings.TrimSpace(fmt.Sprint(page["nextCursor"]))
|
||||
if nextCursor == "" || nextCursor == "<nil>" || seenCursors[nextCursor] {
|
||||
result.failures = append(result.failures, map[string]any{
|
||||
"bucket": "users",
|
||||
"page": result.pagesFetched,
|
||||
"stage": "pagination",
|
||||
"error": "群成员 hasMore=true 但 nextCursor 缺失或停滞",
|
||||
})
|
||||
result.stopReason = "pagination_error"
|
||||
return result, nil
|
||||
}
|
||||
result.nextCursor = nextCursor
|
||||
seenCursors[nextCursor] = true
|
||||
cursor = nextCursor
|
||||
}
|
||||
result.stopReason = "page_limit"
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (result groupUserMembersResult) bucketPayload() map[string]any {
|
||||
payload := map[string]any{
|
||||
"count": len(result.members),
|
||||
"pagesFetched": result.pagesFetched,
|
||||
"complete": result.complete && len(result.failures) == 0,
|
||||
"hasMore": result.hasMore,
|
||||
"partial": len(result.failures) > 0 && len(result.members) > 0,
|
||||
"stopReason": result.stopReason,
|
||||
"failedCount": len(result.failures),
|
||||
"failures": result.failures,
|
||||
}
|
||||
if result.nextCursor != "" {
|
||||
payload["nextCursor"] = result.nextCursor
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func (result groupUserMembersResult) payload(groupID string) map[string]any {
|
||||
payload := result.bucketPayload()
|
||||
payload["contractVersion"] = groupMembersContractVersion
|
||||
payload["conversationId"] = groupID
|
||||
payload["members"] = result.members
|
||||
return payload
|
||||
}
|
||||
|
||||
func resolveMemberTypes(raw []string) (bool, bool, error) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package smart
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
func defaultChatPageLimit(value, fallback int) int {
|
||||
if value == 0 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func validChatTime(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} {
|
||||
if _, err := time.Parse(layout, value); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func localChatOptionError(reason, message string, flags ...string) error {
|
||||
action := "修正参数后重试,或查看当前命令帮助"
|
||||
if flagText := strings.Join(flags, "、"); flagText != "" {
|
||||
action = "检查 " + flagText + " 后重试,或查看当前命令帮助"
|
||||
}
|
||||
return apperrors.NewValidation(
|
||||
message,
|
||||
apperrors.WithReason(reason),
|
||||
apperrors.WithActions(action),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package smart
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesResolvesNaturalChatAndUserTargets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
wantKey string
|
||||
wantValue string
|
||||
}{
|
||||
{
|
||||
name: "chat query",
|
||||
args: []string{"chat", "+chat-messages", "--chat-query", "项目冲刺"},
|
||||
wantTool: "list_conversation_message_v2",
|
||||
wantKey: "openconversation_id",
|
||||
wantValue: "cid-1",
|
||||
},
|
||||
{
|
||||
name: "natural group through group flag",
|
||||
args: []string{"chat", "+chat-messages", "--group", "项目冲刺"},
|
||||
wantTool: "list_conversation_message_v2",
|
||||
wantKey: "openconversation_id",
|
||||
wantValue: "cid-1",
|
||||
},
|
||||
{
|
||||
name: "user query",
|
||||
args: []string{"chat", "+chat-messages", "--user-query", "张三"},
|
||||
wantTool: "list_individual_chat_message",
|
||||
wantKey: "openDingTalkId",
|
||||
wantValue: "open1",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fake := &platformCoverageCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(tt.args)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 2 {
|
||||
t.Fatalf("calls = %#v, want resolve + read", fake.calls)
|
||||
}
|
||||
read := fake.calls[1]
|
||||
if read.tool != tt.wantTool || read.args[tt.wantKey] != tt.wantValue {
|
||||
t.Fatalf("read = %#v", read)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesStableGroupBypassesNaturalResolution(t *testing.T) {
|
||||
fake := &platformCoverageCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+chat-messages", "--group", "cid-fixture-chat-0001"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "list_conversation_message_v2" ||
|
||||
fake.calls[0].args["openconversation_id"] != "cid-fixture-chat-0001" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesNaturalUserAmbiguityStopsBeforeMessageRead(t *testing.T) {
|
||||
fake := &platformCoverageCaller{contactSearchResult: `{"result":[{"name":"张三","userId":"u1","openDingTalkId":"D1"},{"name":"张三","userId":"u2","openDingTalkId":"D2"}]}`}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+chat-messages", "--user-query", "张三"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("ambiguous user unexpectedly reached message read")
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "search_contact_by_key_word" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesRejectsConversationIDInPeerIdentityFlag(t *testing.T) {
|
||||
fake := &platformCoverageCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+chat-messages", "--open-dingtalk-id", "cid-fixture-chat-0001"})
|
||||
err := root.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "--group") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("invalid identity reached lower API: %#v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAtMeResolvesNaturalGroupBeforeSearch(t *testing.T) {
|
||||
fake := &platformCoverageCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+at-me", "--chat-query", "项目冲刺"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 2 || fake.calls[1].tool != "search_at_me_message" || fake.calls[1].args["openConversationId"] != "cid-1" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAtMeStableIDInQueryBypassesNaturalResolution(t *testing.T) {
|
||||
fake := &platformCoverageCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+at-me", "--chat-query", "cid-fixture-chat-0001"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "search_at_me_message" ||
|
||||
fake.calls[0].args["openConversationId"] != "cid-fixture-chat-0001" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSendToGroupStableIDBypassesNaturalResolution(t *testing.T) {
|
||||
fake := &platformCoverageCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+send-to-group", "--group", "cid-fixture-chat-0001", "--text", "评测", "--yes"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "send_personal_message" ||
|
||||
fake.calls[0].args["openConversationId"] != "cid-fixture-chat-0001" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgResolvesNaturalChatAndSenderBeforeSearch(t *testing.T) {
|
||||
fake := &platformCoverageCaller{contactSearchResult: `{"result":[{"name":"张三","userId":"u1","openDingTalkId":"D1"}]}`}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+search-msg",
|
||||
"--chat-query", "项目冲刺",
|
||||
"--sender-query", "张三",
|
||||
"--no-enrich",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 3 {
|
||||
t.Fatalf("calls = %#v, want chat resolve + user resolve + search", fake.calls)
|
||||
}
|
||||
search := fake.calls[2]
|
||||
if search.product != "im" || search.tool != "search_messages" {
|
||||
t.Fatalf("search = %#v", search)
|
||||
}
|
||||
if got, want := search.args["openConversationIds"], []string{"cid-1"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("openConversationIds = %#v, want %#v", got, want)
|
||||
}
|
||||
if got, want := search.args["senderOpenDingTakIds"], []string{"D1"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("senderOpenDingTakIds = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgAcceptsStableIDInChatQuery(t *testing.T) {
|
||||
fake := &platformCoverageCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+search-msg",
|
||||
"--chat-query", "cid-fixture-chat-0002",
|
||||
"--text", "评测",
|
||||
"--no-enrich",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "search_messages" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
if got, want := fake.calls[0].args["openConversationIds"], []string{"cid-fixture-chat-0002"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("openConversationIds = %#v, want %#v", got, want)
|
||||
}
|
||||
if fake.calls[0].args["keyword"] != "评测" {
|
||||
t.Fatalf("keyword = %#v", fake.calls[0].args["keyword"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMembersListGroupAcceptsNameAndStableID(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
args []string
|
||||
wantCalls int
|
||||
}{
|
||||
{name: "name", args: []string{"chat", "+chat-members-list", "--group", "项目冲刺", "--member-types", "user"}, wantCalls: 2},
|
||||
{name: "stable id", args: []string{"chat", "+chat-members-list", "--group", "cid-fixture-chat-0001", "--member-types", "user"}, wantCalls: 1},
|
||||
{name: "compat alias", args: []string{"chat", "+chat-members-list", "--open-conversation-id", "cid-short-placeholder", "--member-types", "user"}, wantCalls: 1},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fake := &platformCoverageCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(tt.args)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != tt.wantCalls || fake.calls[len(fake.calls)-1].tool != "get_group_members" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgNaturalSenderAmbiguityStopsBeforeSearch(t *testing.T) {
|
||||
fake := &platformCoverageCaller{contactSearchResult: `{"result":[{"name":"张三","userId":"u1","openDingTalkId":"D1"},{"name":"张三","userId":"u2","openDingTalkId":"D2"}]}`}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+search-msg", "--sender-query", "张三", "--no-enrich"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("ambiguous sender unexpectedly reached search")
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "search_contact_by_key_word" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
}
|
||||
@@ -1,131 +1,87 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package smart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
)
|
||||
|
||||
// contactUser is the minimal identity a smart shortcut needs after resolving a
|
||||
// name.
|
||||
// contactUser preserves the smart package's internal shape while resolution
|
||||
// semantics live in targetresolver for reuse by chat and future facades.
|
||||
type contactUser struct {
|
||||
userID string
|
||||
openDingTalkID string
|
||||
name string
|
||||
}
|
||||
|
||||
// resolveUser turns a human name into a single unique contact, the "name → ID"
|
||||
// intelligence that distinguishes a smart shortcut from a raw tool call
|
||||
// (mirrors the shared name→ID resolver). It searches the directory and:
|
||||
// - errors with a clear message if nobody matches;
|
||||
// - errors listing the candidates if the name is ambiguous (never guesses).
|
||||
func resolveUser(rt *shortcut.RuntimeContext, name string) (contactUser, error) {
|
||||
return resolveUserByName(rt, name, false)
|
||||
}
|
||||
|
||||
// resolveOpenDingTalkUser also accepts external / cross-org contacts that have
|
||||
// an openDingTalkId but no organization-scoped userId. Use it only for flows
|
||||
// whose downstream interface consumes openDingTalkId.
|
||||
func resolveOpenDingTalkUser(rt *shortcut.RuntimeContext, name string) (contactUser, error) {
|
||||
return resolveUserByName(rt, name, true)
|
||||
}
|
||||
|
||||
func resolveUserByName(rt *shortcut.RuntimeContext, name string, includeOpenIDOnly bool) (contactUser, error) {
|
||||
data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{
|
||||
"keyword": name,
|
||||
})
|
||||
requirement := targetresolver.IdentityUserID
|
||||
if includeOpenIDOnly {
|
||||
requirement = targetresolver.IdentityOpenDingTalkID
|
||||
}
|
||||
resolved, err := targetresolver.ResolveUser(rt, name, requirement)
|
||||
if err != nil {
|
||||
return contactUser{}, err
|
||||
}
|
||||
users := extractUsers(data)
|
||||
if !includeOpenIDOnly {
|
||||
users = usersWithUserID(users)
|
||||
}
|
||||
switch {
|
||||
case len(users) == 0:
|
||||
return contactUser{}, apperrors.NewValidation(
|
||||
fmt.Sprintf("通讯录里没找到叫 %q 的人;换个更完整的姓名再试。", name))
|
||||
case len(users) > 1:
|
||||
return contactUser{}, apperrors.NewValidation(fmt.Sprintf(
|
||||
"%q 匹配到 %d 个人:%s。请用更精确的姓名,或改用对应命令直接传用户 ID。",
|
||||
name, len(users), strings.Join(userLabels(users), "、")))
|
||||
}
|
||||
return users[0], nil
|
||||
return fromResolvedUser(resolved.Selected), nil
|
||||
}
|
||||
|
||||
func usersWithUserID(users []contactUser) []contactUser {
|
||||
out := make([]contactUser, 0, len(users))
|
||||
resolved := make([]targetresolver.User, 0, len(users))
|
||||
for _, user := range users {
|
||||
if user.userID != "" {
|
||||
out = append(out, user)
|
||||
}
|
||||
resolved = append(resolved, toResolvedUser(user))
|
||||
}
|
||||
filtered := targetresolver.UsersWithUserID(resolved)
|
||||
out := make([]contactUser, 0, len(filtered))
|
||||
for _, user := range filtered {
|
||||
out = append(out, fromResolvedUser(user))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// extractUsers pulls {userId, openDingTalkId, name} out of a
|
||||
// search_contact_by_key_word response ({"result": [ {userId, name, ...} ]}).
|
||||
func extractUsers(data map[string]any) []contactUser {
|
||||
raw, ok := data["result"].([]any)
|
||||
if !ok {
|
||||
resolved := targetresolver.ExtractUsers(data)
|
||||
if len(resolved) == 0 {
|
||||
return nil
|
||||
}
|
||||
var out []contactUser
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, _ := m["userId"].(string)
|
||||
openID, _ := m["openDingTalkId"].(string)
|
||||
if openID == "" {
|
||||
openID, _ = m["openDingtalkId"].(string)
|
||||
}
|
||||
// External / cross-org contacts come back with an empty userId and only
|
||||
// an openDingTalkId (verified live). Dropping them here made name→ID
|
||||
// resolution miss those people, or collapse to the wrong single match
|
||||
// when an in-org namesake also existed. Keep any row with at least one
|
||||
// usable identity; downstream (e.g. +dm) can act on the openDingTalkId.
|
||||
if id == "" && openID == "" {
|
||||
continue
|
||||
}
|
||||
nm, _ := m["name"].(string)
|
||||
if nm == "" {
|
||||
for _, k := range []string{"nick", "showName", "flowerName", "staffName", "userName"} {
|
||||
if v, _ := m[k].(string); v != "" {
|
||||
nm = v
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, contactUser{userID: id, openDingTalkID: openID, name: nm})
|
||||
out := make([]contactUser, 0, len(resolved))
|
||||
for _, user := range resolved {
|
||||
out = append(out, fromResolvedUser(user))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func userLabels(users []contactUser) []string {
|
||||
out := make([]string, 0, len(users))
|
||||
for _, u := range users {
|
||||
id := u.userID
|
||||
if id == "" {
|
||||
id = u.openDingTalkID
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s(%s)", u.name, id))
|
||||
resolved := make([]targetresolver.User, 0, len(users))
|
||||
for _, user := range users {
|
||||
resolved = append(resolved, toResolvedUser(user))
|
||||
}
|
||||
return targetresolver.UserLabels(resolved)
|
||||
}
|
||||
|
||||
func fromResolvedUser(user targetresolver.User) contactUser {
|
||||
return contactUser{
|
||||
userID: user.UserID,
|
||||
openDingTalkID: user.OpenDingTalkID,
|
||||
name: user.Name,
|
||||
}
|
||||
}
|
||||
|
||||
func toResolvedUser(user contactUser) targetresolver.User {
|
||||
return targetresolver.User{
|
||||
UserID: user.userID,
|
||||
OpenDingTalkID: user.openDingTalkID,
|
||||
Name: user.name,
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
)
|
||||
|
||||
// SearchMsg is the semantic message-search entry point. It exposes the native
|
||||
@@ -37,10 +38,10 @@ var SearchMsg = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+search-msg",
|
||||
Product: "im",
|
||||
Description: "多维搜索消息,可全量翻页并批量富化详情",
|
||||
Intent: "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天," +
|
||||
"也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" +
|
||||
"--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。",
|
||||
Description: "按稳定 ID 和内容等条件跨会话搜索消息,可全量翻页并批量富化",
|
||||
Intent: "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;会话与发送者过滤使用稳定 ID。默认查询近 7 天,也可指定精确起止时间。" +
|
||||
"--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" +
|
||||
"--download-resources 使用安全本地路径、默认不覆盖和原子落盘。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
@@ -54,15 +55,15 @@ var SearchMsg = shortcut.Shortcut{
|
||||
CLIPath: "chat +search-msg",
|
||||
PrimaryCLIPath: "chat +search-msg",
|
||||
},
|
||||
Description: "多维搜索消息,可全量翻页并批量富化详情",
|
||||
Description: "按稳定 ID 和内容等条件跨会话搜索消息,可全量翻页并批量富化",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed search adapter: it combines filters, cursor pagination, batched mget enrichment, stable projection, completeness accounting, and optional safe resource downloads.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "多维搜索消息,可全量翻页并批量富化详情",
|
||||
UseWhen: []string{"当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"},
|
||||
AgentSummary: "按稳定 ID 和内容等条件跨会话搜索消息,可全量翻页并批量富化",
|
||||
UseWhen: []string{"当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;会话与发送者过滤使用稳定 ID。默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用安全本地路径、默认不覆盖和原子落盘。"},
|
||||
AvoidWhen: []string{"只想读取一个已知会话的连续历史时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"},
|
||||
Examples: []string{
|
||||
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all",
|
||||
@@ -73,13 +74,17 @@ var SearchMsg = shortcut.Shortcut{
|
||||
Flags: append([]shortcut.Flag{
|
||||
{Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词"},
|
||||
{Name: "keyword", Type: shortcut.FlagString, Desc: "--query 的别名", Hidden: true},
|
||||
{Name: "text", Type: shortcut.FlagString, Desc: "--query 的兼容别名", Hidden: true},
|
||||
{Name: "text-query", Type: shortcut.FlagString, Desc: "--query 的兼容别名", Hidden: true},
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"},
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
|
||||
{Name: "groups", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId"},
|
||||
{Name: "chat-id", Type: shortcut.FlagStringSlice, Desc: "--groups 的 lark-cli 对齐别名"},
|
||||
{Name: "chat-query", Type: shortcut.FlagStringSlice, Desc: "按群名解析会话的兼容入口", Hidden: true},
|
||||
{Name: "senders", Type: shortcut.FlagStringSlice, Desc: "发送者 userId/openDingTalkId 列表"},
|
||||
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "--senders 的 lark-cli 对齐别名"},
|
||||
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "按姓名解析发送者的兼容入口", Hidden: true},
|
||||
{Name: "at-me", Type: shortcut.FlagBool, Desc: "只搜索 @我 的消息"},
|
||||
{Name: "is-at-me", Type: shortcut.FlagBool, Desc: "--at-me 的 lark-cli 对齐别名"},
|
||||
{Name: "at-ids", Type: shortcut.FlagStringSlice, Desc: "@对象 userId/openDingTalkId 列表"},
|
||||
@@ -102,7 +107,7 @@ var SearchMsg = shortcut.Shortcut{
|
||||
Constraints: append([]shortcut.Constraint{
|
||||
{
|
||||
Kind: shortcut.ConstraintAtLeastOne,
|
||||
Flags: []string{"query", "keyword", "group", "conversation-id", "id", "groups", "chat-id", "senders", "sender", "at-me", "is-at-me", "at-ids", "message-type", "only-robot", "conversation-type", "chat-type"},
|
||||
Flags: []string{"query", "keyword", "text", "text-query", "group", "conversation-id", "id", "groups", "chat-id", "chat-query", "senders", "sender", "sender-query", "at-me", "is-at-me", "at-ids", "message-type", "only-robot", "conversation-type", "chat-type"},
|
||||
Description: "至少指定一个内容、身份、会话或消息类型过滤条件",
|
||||
},
|
||||
{
|
||||
@@ -119,6 +124,7 @@ var SearchMsg = shortcut.Shortcut{
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"senders", "sender"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"at-me", "is-at-me"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"conversation-type", "chat-type"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"query", "keyword", "text", "text-query"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"limit", "page-size"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"cursor", "page-token"}},
|
||||
}, chatshortcut.MessageResourceDownloadConstraints()...),
|
||||
@@ -145,6 +151,7 @@ var SearchMsg = shortcut.Shortcut{
|
||||
complete := true
|
||||
hasMore := false
|
||||
nextCursor := ""
|
||||
paginationKnown := true
|
||||
|
||||
for pagesFetched < pageLimit {
|
||||
params["cursor"] = cursor
|
||||
@@ -184,6 +191,7 @@ var SearchMsg = shortcut.Shortcut{
|
||||
"stage": "search-pagination",
|
||||
"error": "下层未返回 hasMore 或 nextCursor,无法证明结果完整",
|
||||
})
|
||||
paginationKnown = false
|
||||
complete = false
|
||||
break
|
||||
}
|
||||
@@ -226,20 +234,26 @@ var SearchMsg = shortcut.Shortcut{
|
||||
results = append(results, searchMsgProjectWithReactions(m, !rt.Bool("no-reactions")))
|
||||
}
|
||||
payload := map[string]any{
|
||||
"count": len(results),
|
||||
"messages": results,
|
||||
"pagesFetched": pagesFetched,
|
||||
"enrichedCount": enrichedCount,
|
||||
"complete": complete,
|
||||
"hasMore": hasMore,
|
||||
"failedCount": len(failures),
|
||||
"failures": failures,
|
||||
"contractVersion": chatmsg.MessageListContractVersion,
|
||||
"count": len(results),
|
||||
"messages": results,
|
||||
"pagesFetched": pagesFetched,
|
||||
"enrichedCount": enrichedCount,
|
||||
"complete": complete,
|
||||
"hasMore": hasMore,
|
||||
"nextCursor": "",
|
||||
"paginationKnown": paginationKnown,
|
||||
"failedCount": len(failures),
|
||||
"failures": failures,
|
||||
}
|
||||
if hasMore && nextCursor != "" && nextCursor != "<nil>" {
|
||||
payload["nextCursor"] = nextCursor
|
||||
}
|
||||
if rt.Bool("download-resources") {
|
||||
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, messages, "")
|
||||
chatshortcut.AttachMessageResourceDownloads(
|
||||
payload,
|
||||
chatshortcut.DownloadMessageResources(rt, messages, ""),
|
||||
)
|
||||
}
|
||||
return rt.Output(payload)
|
||||
},
|
||||
@@ -253,11 +267,13 @@ func validateSearchMsgWithResources(rt *shortcut.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func validateSearchMsg(rt *shortcut.RuntimeContext) error {
|
||||
hasFilter := rt.StrFirst("query", "keyword", "group", "conversation-id", "id", "message-type", "conversation-type", "chat-type") != "" ||
|
||||
hasFilter := rt.StrFirst("query", "keyword", "text", "text-query", "group", "conversation-id", "id", "message-type", "conversation-type", "chat-type") != "" ||
|
||||
len(rt.StrSlice("groups")) > 0 ||
|
||||
len(rt.StrSlice("chat-id")) > 0 ||
|
||||
len(rt.StrSlice("chat-query")) > 0 ||
|
||||
len(rt.StrSlice("senders")) > 0 ||
|
||||
len(rt.StrSlice("sender")) > 0 ||
|
||||
len(rt.StrSlice("sender-query")) > 0 ||
|
||||
len(rt.StrSlice("at-ids")) > 0 ||
|
||||
rt.Bool("at-me") ||
|
||||
rt.Bool("is-at-me") ||
|
||||
@@ -283,7 +299,7 @@ func validateSearchMsg(rt *shortcut.RuntimeContext) error {
|
||||
|
||||
func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, error) {
|
||||
params := map[string]any{"limit": rt.IntFirst("limit", "page-size")}
|
||||
if value := rt.StrFirst("query", "keyword"); value != "" {
|
||||
if value := rt.StrFirst("query", "keyword", "text", "text-query"); value != "" {
|
||||
params["keyword"] = value
|
||||
}
|
||||
conversationIDs := append([]string{}, rt.StrSlice("groups")...)
|
||||
@@ -291,11 +307,33 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, error) {
|
||||
if value := rt.StrFirst("group", "conversation-id", "id"); value != "" {
|
||||
conversationIDs = append(conversationIDs, value)
|
||||
}
|
||||
if queries := rt.StrSlice("chat-query"); len(queries) > 0 {
|
||||
for _, query := range queries {
|
||||
resolved, err := targetresolver.ResolveChatTarget(rt, "", query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conversationIDs = append(conversationIDs, resolved.Selected.OpenConversationID)
|
||||
}
|
||||
}
|
||||
if values := uniqueSearchStrings(conversationIDs); len(values) > 0 {
|
||||
params["openConversationIds"] = values
|
||||
}
|
||||
senders := append([]string{}, rt.StrSlice("senders")...)
|
||||
senders = append(senders, rt.StrSlice("sender")...)
|
||||
if queries := rt.StrSlice("sender-query"); len(queries) > 0 {
|
||||
resolvedUsers, err := targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, resolved := range resolvedUsers {
|
||||
identity := resolved.Selected.OpenDingTalkID
|
||||
if identity == "" {
|
||||
identity = resolved.Selected.UserID
|
||||
}
|
||||
senders = append(senders, identity)
|
||||
}
|
||||
}
|
||||
appendSearchActorIDs(params, senders, "senderUserIds", "senderOpenDingTakIds")
|
||||
appendSearchActorIDs(params, rt.StrSlice("at-ids"), "atUserIds", "atOpenDingTakIds")
|
||||
if rt.Bool("at-me") || rt.Bool("is-at-me") {
|
||||
@@ -530,39 +568,16 @@ func searchMsgProject(m map[string]any) map[string]any {
|
||||
}
|
||||
|
||||
func searchMsgProjectWithReactions(m map[string]any, includeReactions bool) map[string]any {
|
||||
row := map[string]any{
|
||||
"sender": searchMsgSender(m),
|
||||
"time": searchMsgTime(m),
|
||||
"text": searchMsgCleanText(m),
|
||||
"messageId": searchMsgMessageID(m),
|
||||
}
|
||||
if conversationID := chatmsg.ConversationID(m); conversationID != nil {
|
||||
row["conversationId"] = conversationID
|
||||
}
|
||||
if threadID := chatmsg.ThreadID(m); threadID != nil {
|
||||
row["threadId"] = threadID
|
||||
}
|
||||
if messageType := chatmsg.MessageType(m); messageType != nil {
|
||||
row["messageType"] = messageType
|
||||
}
|
||||
if updateTime := chatmsg.UpdateTime(m); updateTime != nil {
|
||||
row["updateTime"] = updateTime
|
||||
}
|
||||
if includeReactions {
|
||||
if reactions := chatmsg.Reactions(m); len(reactions) > 0 {
|
||||
row["reactions"] = reactions
|
||||
}
|
||||
}
|
||||
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
|
||||
row["quotedMessage"] = quoted
|
||||
}
|
||||
if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
|
||||
row["resourceRefs"] = resources
|
||||
}
|
||||
projectForwarded := func(item map[string]any) map[string]any {
|
||||
row := chatmsg.ProjectMessageV1(m, includeReactions)
|
||||
// Preserve the established search aliases while also publishing the V1
|
||||
// canonical fields used by other message readers.
|
||||
row["sender"] = searchMsgSender(m)
|
||||
row["time"] = searchMsgTime(m)
|
||||
row["text"] = searchMsgCleanText(m)
|
||||
row["messageId"] = searchMsgMessageID(m)
|
||||
if forwarded := chatmsg.Forwarded(m, func(item map[string]any) map[string]any {
|
||||
return searchMsgProjectWithReactions(item, includeReactions)
|
||||
}
|
||||
if forwarded := chatmsg.Forwarded(m, projectForwarded); len(forwarded) > 0 {
|
||||
}); len(forwarded) > 0 {
|
||||
row["forwarded"] = forwarded
|
||||
}
|
||||
return row
|
||||
|
||||
@@ -14,21 +14,18 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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"
|
||||
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
)
|
||||
|
||||
// SendToGroup: message a group by its NAME, no openConversationId juggling.
|
||||
// SendToGroup: message a group by its name or stable openConversationId.
|
||||
//
|
||||
// Steps: search groups by name → resolve to a single openConversationId
|
||||
// (disambiguate on multiple matches, never guess) → send a markdown message.
|
||||
// Steps: normalize a stable ID, or search a name and resolve it to a single
|
||||
// openConversationId (disambiguate on multiple matches, never guess), then
|
||||
// send a markdown message.
|
||||
// Replaces `chat search --query <群名>` (copy openConversationId) →
|
||||
// `chat +messages-send --group <openConversationId>`.
|
||||
//
|
||||
@@ -41,10 +38,9 @@ var SendToGroup = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+send-to-group",
|
||||
Product: "chat",
|
||||
Description: "按群名直接给群发消息(自动搜群解析 openConversationId)",
|
||||
Intent: "当你只知道群的名字、想直接往这个群里发一条消息而不想先手动查群 ID 时使用;" +
|
||||
"内部先按群名搜索群聊解析出唯一 openConversationId 再发送,群名匹配到多个群时会列出候选让你区分、绝不自行假定。会真实发出群消息。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Description: "按群名或 openConversationId 直接给群发消息",
|
||||
Intent: "当你有群名或 openConversationId、想直接往该群发送简单文本或 Markdown 时使用;稳定 ID 不进入搜索,群名则必须唯一解析,零命中或多候选都会在发送前停止。会真实发出群消息。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
@@ -57,21 +53,21 @@ var SendToGroup = shortcut.Shortcut{
|
||||
CLIPath: "chat +send-to-group",
|
||||
PrimaryCLIPath: "chat +send-to-group",
|
||||
},
|
||||
Description: "按群名直接给群发消息(自动搜群解析 openConversationId)",
|
||||
Description: "按群名或 openConversationId 直接给群发消息",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按群名直接给群发消息(自动搜群解析 openConversationId)",
|
||||
UseWhen: []string{"当你只知道群的名字、想直接往这个群里发一条消息而不想先手动查群 ID 时使用;内部先按群名搜索群聊解析出唯一 openConversationId 再发送,群名匹配到多个群时会列出候选让你区分、绝不自行假定。会真实发出群消息。"},
|
||||
AgentSummary: "按群名或 openConversationId 直接给群发消息",
|
||||
UseWhen: []string{"当你有群名或 openConversationId、想直接往该群发送简单文本或 Markdown 时使用;稳定 ID 不进入搜索,群名则必须唯一解析,零命中或多候选都会在发送前停止。会真实发出群消息。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws chat +send-to-group --group 项目冲刺 --text \"今天 5 点前提交进度\""},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群名称(搜群关键词,用群名里连续的核心词)", Required: true},
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群名称或 openConversationId", Required: true},
|
||||
{Name: "text", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown)", Required: true},
|
||||
shortcut.AIMessageTagFlag(),
|
||||
},
|
||||
@@ -80,33 +76,17 @@ var SendToGroup = shortcut.Shortcut{
|
||||
groupName := rt.Str("group")
|
||||
text := rt.Str("text")
|
||||
|
||||
// Step 1 — search groups by name (keyword) on the im server.
|
||||
data, err := rt.CallMCPData("im", "search_groups", map[string]any{
|
||||
"keyword": groupName,
|
||||
"limit": 10,
|
||||
"cursor": "0",
|
||||
})
|
||||
resolved, err := targetresolver.ResolveChatTarget(rt, groupName, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groups := preferExactGroupMatches(extractGroupsForSend(data), groupName)
|
||||
switch {
|
||||
case len(groups) == 0:
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"没找到名字匹配 %q 的群;换用群名里连续的核心词再试。", groupName))
|
||||
case len(groups) > 1:
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"%q 匹配到 %d 个群:%s。请用更精确的群名,或直接用 dws chat +messages-send --group <openConversationId> 指定群。",
|
||||
groupName, len(groups), strings.Join(sendGroupLabels(groups), "、")))
|
||||
}
|
||||
|
||||
// Step 2 — send the markdown message to the unique group.
|
||||
content, _ := json.Marshal(map[string]string{"title": text, "text": text})
|
||||
return rt.CallMCP("send_personal_message", rt.AddAIMessageTag(map[string]any{
|
||||
"openConversationId": groups[0].id,
|
||||
"msgType": "markdown",
|
||||
"content": string(content),
|
||||
}))
|
||||
return chatshortcut.ExecuteResolvedUserMarkdown(
|
||||
rt,
|
||||
chatshortcut.ResolvedUserMessageTarget{
|
||||
GroupID: resolved.Selected.OpenConversationID,
|
||||
},
|
||||
text,
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -121,41 +101,10 @@ type sendGroupMatch struct {
|
||||
// result/result.items/result.groups (field names per chat search's real
|
||||
// response shape), and the name field may be "title" or "name".
|
||||
func extractGroupsForSend(data map[string]any) []sendGroupMatch {
|
||||
var list []any
|
||||
switch inner := data["result"].(type) {
|
||||
case []any:
|
||||
list = inner
|
||||
case map[string]any:
|
||||
if v, ok := inner["items"].([]any); ok {
|
||||
list = v
|
||||
} else if v, ok := inner["groups"].([]any); ok {
|
||||
list = v
|
||||
}
|
||||
}
|
||||
if list == nil {
|
||||
if v, ok := data["items"].([]any); ok {
|
||||
list = v
|
||||
}
|
||||
}
|
||||
|
||||
var out []sendGroupMatch
|
||||
for _, item := range list {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, _ := m["openConversationId"].(string)
|
||||
if id == "" {
|
||||
id, _ = m["id"].(string)
|
||||
}
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
name, _ := m["title"].(string)
|
||||
if name == "" {
|
||||
name, _ = m["name"].(string)
|
||||
}
|
||||
out = append(out, sendGroupMatch{id: id, name: name})
|
||||
chats := targetresolver.ExtractChats(data)
|
||||
out := make([]sendGroupMatch, 0, len(chats))
|
||||
for _, chat := range chats {
|
||||
out = append(out, sendGroupMatch{id: chat.OpenConversationID, name: chat.Name})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -166,38 +115,24 @@ func extractGroupsForSend(data map[string]any) []sendGroupMatch {
|
||||
// group wins over prefix/suffix matches. Duplicate rows for the same
|
||||
// openConversationId are collapsed before selection.
|
||||
func preferExactGroupMatches(groups []sendGroupMatch, query string) []sendGroupMatch {
|
||||
unique := make([]sendGroupMatch, 0, len(groups))
|
||||
seen := make(map[string]struct{}, len(groups))
|
||||
chats := make([]targetresolver.Chat, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
if _, ok := seen[group.id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[group.id] = struct{}{}
|
||||
unique = append(unique, group)
|
||||
chats = append(chats, targetresolver.Chat{OpenConversationID: group.id, Name: group.name})
|
||||
}
|
||||
query = strings.TrimSpace(query)
|
||||
exact := make([]sendGroupMatch, 0, 1)
|
||||
for _, group := range unique {
|
||||
if strings.EqualFold(strings.TrimSpace(group.name), query) {
|
||||
exact = append(exact, group)
|
||||
}
|
||||
selected := targetresolver.PreferExactChats(chats, query)
|
||||
out := make([]sendGroupMatch, 0, len(selected))
|
||||
for _, chat := range selected {
|
||||
out = append(out, sendGroupMatch{id: chat.OpenConversationID, name: chat.Name})
|
||||
}
|
||||
if len(exact) > 0 {
|
||||
return exact
|
||||
}
|
||||
return unique
|
||||
return out
|
||||
}
|
||||
|
||||
func sendGroupLabels(groups []sendGroupMatch) []string {
|
||||
out := make([]string, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
name := g.name
|
||||
if name == "" {
|
||||
name = "(未命名群)"
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s(%s)", name, g.id))
|
||||
chats := make([]targetresolver.Chat, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
chats = append(chats, targetresolver.Chat{OpenConversationID: group.id, Name: group.name})
|
||||
}
|
||||
return out
|
||||
return targetresolver.ChatLabels(chats)
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
@@ -78,8 +80,8 @@ var ThreadReplies = shortcut.Shortcut{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群会话 ID(openConversationId,必填)", Required: true},
|
||||
{Name: "thread-id", Type: shortcut.FlagString, Desc: "话题/线程 ID(可直接使用消息列表返回的 threadId)"},
|
||||
{Name: "topic-id", Type: shortcut.FlagString, Desc: "--thread-id 的兼容别名"},
|
||||
{Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"(可选)"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的回复条数(可选)"},
|
||||
{Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\";--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD(可选)"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的回复条数;--limit 必须大于 0"},
|
||||
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出回复 reaction(默认输出)"},
|
||||
}, chatshortcut.MessageResourceDownloadFlags()...),
|
||||
Constraints: append([]shortcut.Constraint{
|
||||
@@ -88,12 +90,14 @@ var ThreadReplies = shortcut.Shortcut{
|
||||
Flags: []string{"thread-id", "topic-id"},
|
||||
Description: "--thread-id 与兼容参数 --topic-id 必须且只能指定一个",
|
||||
},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"time"}, Description: "--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0"},
|
||||
}, chatshortcut.MessageResourceDownloadConstraints()...),
|
||||
Tips: []string{
|
||||
`dws chat +thread-replies --group <openconversationId> --thread-id <threadId>`,
|
||||
`dws chat +thread-replies --group <openconversationId> --thread-id <threadId> --time "2025-03-01 00:00:00" --limit 20`,
|
||||
},
|
||||
Validate: chatshortcut.ValidateMessageResourceDownload,
|
||||
Validate: validateThreadReplies,
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
// Step 1 — fetch the topic replies. Param keys (openconversationId /
|
||||
// topicId / startTime / pageSize) are copied verbatim from chat.go's
|
||||
@@ -129,12 +133,28 @@ var ThreadReplies = shortcut.Shortcut{
|
||||
}
|
||||
chatmsg.ApplyMessagePagination(payload, data, items, "older")
|
||||
if rt.Bool("download-resources") {
|
||||
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, rt.Str("group"))
|
||||
chatshortcut.AttachMessageResourceDownloads(
|
||||
payload,
|
||||
chatshortcut.DownloadMessageResources(rt, items, rt.Str("group")),
|
||||
)
|
||||
}
|
||||
return rt.Output(payload)
|
||||
},
|
||||
}
|
||||
|
||||
func validateThreadReplies(rt *shortcut.RuntimeContext) error {
|
||||
if err := chatshortcut.ValidateMessageResourceDownload(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
if rt.Changed("limit") && rt.Int("limit") <= 0 {
|
||||
return localChatOptionError("invalid_page_size", "+thread-replies 的 --limit 必须大于 0", "--limit")
|
||||
}
|
||||
if value := strings.TrimSpace(rt.Str("time")); value != "" && !validChatTime(value) {
|
||||
return localChatOptionError("invalid_time_boundary", "+thread-replies 的 --time 格式无效", "--time")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// threadReplyItems defensively unwraps the reply list from the response,
|
||||
// tolerating the common container keys and one level of nesting under a
|
||||
// "result"/"data" wrapper.
|
||||
|
||||
@@ -82,14 +82,23 @@ var UnreadChats = shortcut.Shortcut{
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "count", Type: shortcut.FlagInt, Desc: "返回未读会话条数(可选,不传则使用服务端默认值)", Required: false},
|
||||
{Name: "count", Type: shortcut.FlagInt, Desc: "返回未读会话条数;显式 --count 必须大于 0,不传则使用服务端默认值", Required: false},
|
||||
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "是否排除已设置免打扰的会话(可选,默认 false)", Required: false},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"count"}, Description: "显式 --count 必须大于 0"},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws chat +unread-chats`,
|
||||
`dws chat +unread-chats --count 20`,
|
||||
`dws chat +unread-chats --exclude-muted`,
|
||||
},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Changed("count") && rt.Int("count") <= 0 {
|
||||
return localChatOptionError("invalid_page_size", "+unread-chats 的 --count 必须大于 0", "--count")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
// Build params exactly like chatMessageListUnreadConversationsCmd: count is
|
||||
// only sent when > 0, excludeMuted only when true.
|
||||
|
||||
@@ -0,0 +1,797 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
// Package targetresolver provides deterministic natural-target resolution for
|
||||
// shortcuts. It owns extraction, stable-ID de-duplication, exact-match
|
||||
// preference, ambiguity handling, and the machine-readable resolution error
|
||||
// envelope shared by send/read/search/create/event facades.
|
||||
package targetresolver
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/profilectx"
|
||||
)
|
||||
|
||||
// Status is the stable resolution outcome used in successful plans and errors.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusResolved Status = "resolved"
|
||||
StatusAmbiguous Status = "ambiguous"
|
||||
StatusNotFound Status = "not_found"
|
||||
StatusIncomplete Status = "incomplete"
|
||||
)
|
||||
|
||||
const (
|
||||
chatResolutionPageSize = 10
|
||||
chatResolutionPageLimit = 40
|
||||
userSearchDefaultResultLimit = 20
|
||||
)
|
||||
|
||||
// IdentityRequirement filters contacts to identities accepted by the
|
||||
// downstream interface.
|
||||
type IdentityRequirement string
|
||||
|
||||
const (
|
||||
IdentityAny IdentityRequirement = "any"
|
||||
IdentityUserID IdentityRequirement = "user_id"
|
||||
IdentityOpenDingTalkID IdentityRequirement = "open_dingtalk_id"
|
||||
)
|
||||
|
||||
// Reader is the minimal read-only transport needed by natural target
|
||||
// resolution. Shortcut RuntimeContext implements it, and native facades (for
|
||||
// example event +listen-im) can provide an adapter without duplicating
|
||||
// extraction or ambiguity rules.
|
||||
type Reader interface {
|
||||
CallMCPData(product, tool string, params map[string]any) (map[string]any, error)
|
||||
}
|
||||
|
||||
// User is the public, credential-free identity returned by contact resolution.
|
||||
type User struct {
|
||||
UserID string `json:"userId,omitempty"`
|
||||
OpenDingTalkID string `json:"openDingTalkId,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// UserResolution is the typed successful user-resolution result.
|
||||
type UserResolution struct {
|
||||
Status Status `json:"status"`
|
||||
EntityType string `json:"entityType"`
|
||||
Query string `json:"query"`
|
||||
MatchType string `json:"matchType"`
|
||||
Selected User `json:"selected"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
}
|
||||
|
||||
// Chat is the public identity returned by group resolution.
|
||||
type Chat struct {
|
||||
OpenConversationID string `json:"openConversationId"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// ChatResolution is the typed successful group-resolution result.
|
||||
type ChatResolution struct {
|
||||
Status Status `json:"status"`
|
||||
EntityType string `json:"entityType"`
|
||||
Query string `json:"query"`
|
||||
MatchType string `json:"matchType"`
|
||||
Selected Chat `json:"selected"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
}
|
||||
|
||||
// ResolveUser searches the current profile's directory and resolves query to
|
||||
// exactly one identity accepted by the downstream interface.
|
||||
func ResolveUser(rt Reader, query string, requirement IdentityRequirement) (UserResolution, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{
|
||||
"keyword": query,
|
||||
})
|
||||
if err != nil {
|
||||
return UserResolution{}, err
|
||||
}
|
||||
if cause := incompleteUserSearchCause(data, "通讯录搜索"); cause != "" {
|
||||
return UserResolution{}, newIncompleteUserResolutionError(
|
||||
query,
|
||||
dedupeUsers(ExtractUsers(data)),
|
||||
cause,
|
||||
)
|
||||
}
|
||||
return resolveUserCandidates(ExtractUsers(data), query, requirement)
|
||||
}
|
||||
|
||||
// ResolveEnterpriseUser resolves an organization member through the same
|
||||
// name-calibrated enterprise search used by `dws aisearch person`. Unlike the
|
||||
// contact search, this source understands organization nicknames and aliases.
|
||||
func ResolveEnterpriseUser(rt Reader, query string, requirement IdentityRequirement) (UserResolution, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
data, err := rt.CallMCPData("aisearch", "enterprise_person_search", map[string]any{
|
||||
"keyword": query,
|
||||
"dimension": []string{"name"},
|
||||
})
|
||||
if err != nil {
|
||||
return UserResolution{}, err
|
||||
}
|
||||
if cause := incompleteUserSearchCause(data, "企业人员搜索"); cause != "" {
|
||||
return UserResolution{}, newIncompleteUserResolutionError(
|
||||
query,
|
||||
dedupeUsers(ExtractUsers(data)),
|
||||
cause,
|
||||
)
|
||||
}
|
||||
return resolveUserCandidates(ExtractUsers(data), query, requirement)
|
||||
}
|
||||
|
||||
// incompleteUserSearchCause enforces completeness for the two public user
|
||||
// search RPCs. Their discovered input contracts do not accept any cursor or
|
||||
// page parameter, while search_contact_by_key_word is observed to cap an
|
||||
// unqualified result at 20. An explicit terminal hasMore=false proves the page
|
||||
// complete; otherwise a continuation marker or a full default page must fail
|
||||
// closed instead of treating a first-page match as globally unique.
|
||||
func incompleteUserSearchCause(data map[string]any, source string) string {
|
||||
page := extractChatPagination(data)
|
||||
if page.hasMoreKnown {
|
||||
if page.hasMore {
|
||||
return source + "返回了 hasMore=true,但公开接口不接受游标参数,无法安全读取后续候选"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if page.nextCursor != "" {
|
||||
return source + "返回了后续游标,但公开接口不接受游标参数,无法安全读取后续候选"
|
||||
}
|
||||
if len(firstList(data, "result", "items", "users", "list")) >= userSearchDefaultResultLimit {
|
||||
return fmt.Sprintf("%s返回了满额 %d 条候选且没有结束标记,无法证明后续不存在同名用户", source, userSearchDefaultResultLimit)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveUserCandidates(candidates []User, query string, requirement IdentityRequirement) (UserResolution, error) {
|
||||
users := filterUsersByIdentity(dedupeUsers(candidates), requirement)
|
||||
selected, matchType := selectUsers(users, query)
|
||||
if len(selected) == 0 {
|
||||
return UserResolution{}, newResolutionError(StatusNotFound, "user", query, users)
|
||||
}
|
||||
if len(selected) > 1 {
|
||||
return UserResolution{}, newResolutionError(StatusAmbiguous, "user", query, selected)
|
||||
}
|
||||
return UserResolution{
|
||||
Status: StatusResolved,
|
||||
EntityType: "user",
|
||||
Query: query,
|
||||
MatchType: matchType,
|
||||
Selected: selected[0],
|
||||
Profile: profilectx.Get(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveChat searches groups and resolves query to exactly one stable
|
||||
// openConversationId. Exact names win over substring matches, but multiple
|
||||
// exact names remain ambiguous.
|
||||
func ResolveChat(rt Reader, query string) (ChatResolution, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if LooksLikeOpenConversationID(query) {
|
||||
return ChatResolution{}, apperrors.NewValidation(fmt.Sprintf(
|
||||
"群目标参数类型不匹配:%q 符合 openConversationId 格式,当前参数仅接受群名称",
|
||||
query,
|
||||
),
|
||||
apperrors.WithReason("target_type_mismatch"),
|
||||
apperrors.WithOrigin("client"),
|
||||
apperrors.WithFailureStage("target_resolution"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithHint("请使用当前命令的稳定 ID 参数;运行当前 leaf --help 可查看准确参数名。"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"type": "resolution",
|
||||
"entityType": "chat",
|
||||
"providedType": "openConversationId",
|
||||
"expectedType": "chatName",
|
||||
}),
|
||||
)
|
||||
}
|
||||
cursor := "0"
|
||||
seenCursors := map[string]bool{cursor: true}
|
||||
chats := make([]Chat, 0)
|
||||
complete := false
|
||||
for pageNumber := 1; pageNumber <= chatResolutionPageLimit; pageNumber++ {
|
||||
data, err := rt.CallMCPData("im", "search_groups", map[string]any{
|
||||
"keyword": query,
|
||||
"limit": chatResolutionPageSize,
|
||||
"cursor": cursor,
|
||||
})
|
||||
if err != nil {
|
||||
return ChatResolution{}, err
|
||||
}
|
||||
rows := firstList(data, "result", "items", "groups", "list")
|
||||
chats = append(chats, ExtractChats(data)...)
|
||||
page := extractChatPagination(data)
|
||||
switch {
|
||||
case page.hasMoreKnown && !page.hasMore:
|
||||
complete = true
|
||||
case page.hasMoreKnown && page.hasMore:
|
||||
// A usable cursor is required below.
|
||||
case page.nextCursor != "":
|
||||
// Some versions omit hasMore but still publish a continuation cursor.
|
||||
case len(rows) < chatResolutionPageSize:
|
||||
// Compatibility for older responses that return a short bare array
|
||||
// without pagination metadata.
|
||||
complete = true
|
||||
default:
|
||||
return ChatResolution{}, newIncompleteChatResolutionError(
|
||||
query,
|
||||
dedupeChats(chats),
|
||||
"群搜索返回了满页结果,但没有 hasMore 或 nextCursor,无法证明候选已完整读取",
|
||||
)
|
||||
}
|
||||
if complete {
|
||||
break
|
||||
}
|
||||
nextCursor := strings.TrimSpace(page.nextCursor)
|
||||
if nextCursor == "" || seenCursors[nextCursor] {
|
||||
reason := "群搜索声明仍有更多结果,但没有返回可继续的 nextCursor"
|
||||
if nextCursor != "" {
|
||||
reason = fmt.Sprintf("群搜索分页游标停滞在 %q,无法证明候选已完整读取", nextCursor)
|
||||
}
|
||||
return ChatResolution{}, newIncompleteChatResolutionError(
|
||||
query,
|
||||
dedupeChats(chats),
|
||||
reason,
|
||||
)
|
||||
}
|
||||
seenCursors[nextCursor] = true
|
||||
cursor = nextCursor
|
||||
}
|
||||
chats = dedupeChats(chats)
|
||||
if !complete {
|
||||
return ChatResolution{}, newIncompleteChatResolutionError(
|
||||
query,
|
||||
chats,
|
||||
fmt.Sprintf("群搜索达到安全页数上限 %d,仍无法证明候选已完整读取", chatResolutionPageLimit),
|
||||
)
|
||||
}
|
||||
selected, matchType := preferExactChats(chats, query)
|
||||
if len(selected) == 0 {
|
||||
return ChatResolution{}, newResolutionError(StatusNotFound, "chat", query, chats)
|
||||
}
|
||||
if len(selected) > 1 {
|
||||
return ChatResolution{}, newResolutionError(StatusAmbiguous, "chat", query, selected)
|
||||
}
|
||||
return ChatResolution{
|
||||
Status: StatusResolved,
|
||||
EntityType: "chat",
|
||||
Query: query,
|
||||
MatchType: matchType,
|
||||
Selected: selected[0],
|
||||
Profile: profilectx.Get(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveChatTarget accepts either a stable openConversationId or a natural
|
||||
// group name and returns one normalized ChatResolution. Stable IDs never reach
|
||||
// search_groups; natural names retain ResolveChat's complete-pagination and
|
||||
// fail-closed ambiguity semantics.
|
||||
func ResolveChatTarget(rt Reader, directValue, queryValue string) (ChatResolution, error) {
|
||||
directValue = strings.TrimSpace(directValue)
|
||||
queryValue = strings.TrimSpace(queryValue)
|
||||
if directValue != "" && queryValue != "" {
|
||||
return ChatResolution{}, apperrors.NewValidation(
|
||||
"稳定群目标与群名查询参数不能同时指定",
|
||||
apperrors.WithReason("target_arguments_conflict"),
|
||||
apperrors.WithOrigin("client"),
|
||||
apperrors.WithFailureStage("request_validation"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
)
|
||||
}
|
||||
value := directValue
|
||||
if value == "" {
|
||||
value = queryValue
|
||||
}
|
||||
if value == "" {
|
||||
return ChatResolution{}, apperrors.NewValidation(
|
||||
"必须指定群名或 openConversationId",
|
||||
apperrors.WithReason("missing_target"),
|
||||
apperrors.WithOrigin("client"),
|
||||
apperrors.WithFailureStage("request_validation"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
)
|
||||
}
|
||||
if LooksLikeOpenConversationID(value) {
|
||||
return ChatResolution{
|
||||
Status: StatusResolved,
|
||||
EntityType: "chat",
|
||||
Query: value,
|
||||
MatchType: "stable_id",
|
||||
Selected: Chat{OpenConversationID: value},
|
||||
Profile: profilectx.Get(),
|
||||
}, nil
|
||||
}
|
||||
return ResolveChat(rt, value)
|
||||
}
|
||||
|
||||
// LooksLikeOpenConversationID identifies the opaque group-conversation IDs
|
||||
// returned by DingTalk. It is deliberately conservative: only the stable
|
||||
// "cid..." form is recognized, so ordinary short names are still resolved by
|
||||
// search and ambiguity checks.
|
||||
func LooksLikeOpenConversationID(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
return len(value) >= 12 && strings.HasPrefix(strings.ToLower(value), "cid")
|
||||
}
|
||||
|
||||
type chatPagination struct {
|
||||
hasMore bool
|
||||
hasMoreKnown bool
|
||||
nextCursor string
|
||||
}
|
||||
|
||||
func extractChatPagination(data map[string]any) chatPagination {
|
||||
if data == nil {
|
||||
return chatPagination{}
|
||||
}
|
||||
scopes := []map[string]any{data}
|
||||
for _, wrapper := range []string{"result", "data"} {
|
||||
if nested, ok := data[wrapper].(map[string]any); ok {
|
||||
scopes = append(scopes, nested)
|
||||
}
|
||||
}
|
||||
page := chatPagination{}
|
||||
for _, scope := range scopes {
|
||||
if !page.hasMoreKnown {
|
||||
for _, key := range []string{"hasMore", "has_more"} {
|
||||
if value, ok := scope[key].(bool); ok {
|
||||
page.hasMore = value
|
||||
page.hasMoreKnown = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if page.nextCursor == "" {
|
||||
for _, key := range []string{"nextCursor", "next_cursor", "nextToken", "next_token"} {
|
||||
if value, ok := scope[key]; ok {
|
||||
page.nextCursor = paginationString(value)
|
||||
if page.nextCursor != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
func paginationString(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprint(value))
|
||||
if text == "" || text == "<nil>" {
|
||||
return ""
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// ResolveUsers resolves every query before returning. Resolution failures are
|
||||
// collected into one typed envelope; upstream/auth failures still stop
|
||||
// immediately. Successful identities are de-duplicated by stable ID.
|
||||
func ResolveUsers(
|
||||
rt Reader,
|
||||
queries []string,
|
||||
requirement IdentityRequirement,
|
||||
) ([]UserResolution, error) {
|
||||
results := make([]UserResolution, 0, len(queries))
|
||||
failures := make([]map[string]any, 0)
|
||||
for _, query := range uniqueQueries(queries) {
|
||||
resolved, err := ResolveUser(rt, query, requirement)
|
||||
if err != nil {
|
||||
if details, ok := resolutionDetails(err); ok {
|
||||
failures = append(failures, details)
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, resolved)
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return nil, batchResolutionError("user", failures)
|
||||
}
|
||||
seenUserIDs := map[string]bool{}
|
||||
seenOpenIDs := map[string]bool{}
|
||||
deduped := make([]UserResolution, 0, len(results))
|
||||
for _, result := range results {
|
||||
user := result.Selected
|
||||
if (user.UserID != "" && seenUserIDs[user.UserID]) ||
|
||||
(user.OpenDingTalkID != "" && seenOpenIDs[user.OpenDingTalkID]) {
|
||||
continue
|
||||
}
|
||||
seenUserIDs[user.UserID] = user.UserID != ""
|
||||
seenOpenIDs[user.OpenDingTalkID] = user.OpenDingTalkID != ""
|
||||
deduped = append(deduped, result)
|
||||
}
|
||||
return deduped, nil
|
||||
}
|
||||
|
||||
// ResolveChats is the batch equivalent of ResolveChat and returns only after
|
||||
// every query has been preflighted.
|
||||
func ResolveChats(rt Reader, queries []string) ([]ChatResolution, error) {
|
||||
results := make([]ChatResolution, 0, len(queries))
|
||||
failures := make([]map[string]any, 0)
|
||||
for _, query := range uniqueQueries(queries) {
|
||||
resolved, err := ResolveChat(rt, query)
|
||||
if err != nil {
|
||||
if details, ok := resolutionDetails(err); ok {
|
||||
failures = append(failures, details)
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, resolved)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
deduped := make([]ChatResolution, 0, len(results))
|
||||
for _, result := range results {
|
||||
if seen[result.Selected.OpenConversationID] {
|
||||
continue
|
||||
}
|
||||
seen[result.Selected.OpenConversationID] = true
|
||||
deduped = append(deduped, result)
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return nil, batchResolutionError("chat", failures)
|
||||
}
|
||||
return deduped, nil
|
||||
}
|
||||
|
||||
// ExtractUsers accepts the contact search response shapes used by the current
|
||||
// directory MCP and keeps external contacts that only expose openDingTalkId.
|
||||
func ExtractUsers(data map[string]any) []User {
|
||||
raw := firstList(data, "result", "items", "users", "list")
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
users := make([]User, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
row, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
userID := stringValue(row, "userId", "orgUserId")
|
||||
openID := stringValue(row, "openDingTalkId", "openDingtalkId")
|
||||
name := stringValue(row,
|
||||
"name", "nick", "showName", "flowerName", "staffName", "userName", "author", "title")
|
||||
if meta, ok := row["meta"].(map[string]any); ok {
|
||||
if userID == "" {
|
||||
userID = stringValue(meta, "userId", "orgUserId", "staffId")
|
||||
}
|
||||
if openID == "" {
|
||||
openID = stringValue(meta, "openDingTalkId", "openDingtalkId")
|
||||
}
|
||||
if metaName := stringValue(meta, "name", "nick"); metaName != "" {
|
||||
name = metaName
|
||||
}
|
||||
}
|
||||
if userID == "" && openID == "" {
|
||||
continue
|
||||
}
|
||||
users = append(users, User{
|
||||
UserID: userID,
|
||||
OpenDingTalkID: openID,
|
||||
Name: name,
|
||||
})
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
// ExtractChats accepts both bare and wrapped group search result lists.
|
||||
func ExtractChats(data map[string]any) []Chat {
|
||||
raw := firstList(data, "result", "items", "groups", "list")
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
chats := make([]Chat, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
row, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id := stringValue(row, "openConversationId", "openconversationId", "id")
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
chats = append(chats, Chat{
|
||||
OpenConversationID: id,
|
||||
Name: stringValue(row, "title", "name", "conversationName"),
|
||||
})
|
||||
}
|
||||
return chats
|
||||
}
|
||||
|
||||
// UsersWithUserID preserves the legacy smart-shortcut helper contract.
|
||||
func UsersWithUserID(users []User) []User {
|
||||
return filterUsersByIdentity(users, IdentityUserID)
|
||||
}
|
||||
|
||||
// PreferExactChats exposes the deterministic selection helper to legacy smart
|
||||
// shortcuts while they migrate to ResolveChat.
|
||||
func PreferExactChats(chats []Chat, query string) []Chat {
|
||||
selected, _ := preferExactChats(dedupeChats(chats), query)
|
||||
return selected
|
||||
}
|
||||
|
||||
// UserLabels renders credential-free disambiguation labels.
|
||||
func UserLabels(users []User) []string {
|
||||
labels := make([]string, 0, len(users))
|
||||
for _, user := range users {
|
||||
id := user.UserID
|
||||
if id == "" {
|
||||
id = user.OpenDingTalkID
|
||||
}
|
||||
labels = append(labels, fmt.Sprintf("%s(%s)", user.Name, id))
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
// ChatLabels renders group disambiguation labels.
|
||||
func ChatLabels(chats []Chat) []string {
|
||||
labels := make([]string, 0, len(chats))
|
||||
for _, chat := range chats {
|
||||
name := chat.Name
|
||||
if name == "" {
|
||||
name = "(未命名群)"
|
||||
}
|
||||
labels = append(labels, fmt.Sprintf("%s(%s)", name, chat.OpenConversationID))
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
func selectUsers(users []User, query string) ([]User, string) {
|
||||
if len(users) != 1 {
|
||||
return users, "ambiguous"
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(users[0].Name), strings.TrimSpace(query)) {
|
||||
return users, "exact"
|
||||
}
|
||||
return users, "unique"
|
||||
}
|
||||
|
||||
func preferExactChats(chats []Chat, query string) ([]Chat, string) {
|
||||
exact := make([]Chat, 0, len(chats))
|
||||
for _, chat := range chats {
|
||||
if strings.EqualFold(strings.TrimSpace(chat.Name), strings.TrimSpace(query)) {
|
||||
exact = append(exact, chat)
|
||||
}
|
||||
}
|
||||
if len(exact) > 0 {
|
||||
return exact, "exact"
|
||||
}
|
||||
return chats, "unique"
|
||||
}
|
||||
|
||||
func filterUsersByIdentity(users []User, requirement IdentityRequirement) []User {
|
||||
filtered := make([]User, 0, len(users))
|
||||
for _, user := range users {
|
||||
switch requirement {
|
||||
case IdentityUserID:
|
||||
if user.UserID == "" {
|
||||
continue
|
||||
}
|
||||
case IdentityOpenDingTalkID:
|
||||
if user.OpenDingTalkID == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, user)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func dedupeUsers(users []User) []User {
|
||||
result := make([]User, 0, len(users))
|
||||
seenUserIDs := map[string]bool{}
|
||||
seenOpenIDs := map[string]bool{}
|
||||
for _, user := range users {
|
||||
if (user.UserID != "" && seenUserIDs[user.UserID]) ||
|
||||
(user.OpenDingTalkID != "" && seenOpenIDs[user.OpenDingTalkID]) {
|
||||
continue
|
||||
}
|
||||
if user.UserID != "" {
|
||||
seenUserIDs[user.UserID] = true
|
||||
}
|
||||
if user.OpenDingTalkID != "" {
|
||||
seenOpenIDs[user.OpenDingTalkID] = true
|
||||
}
|
||||
result = append(result, user)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func dedupeChats(chats []Chat) []Chat {
|
||||
result := make([]Chat, 0, len(chats))
|
||||
seen := map[string]bool{}
|
||||
for _, chat := range chats {
|
||||
if seen[chat.OpenConversationID] {
|
||||
continue
|
||||
}
|
||||
seen[chat.OpenConversationID] = true
|
||||
result = append(result, chat)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func newResolutionError(status Status, entityType, query string, candidates any) error {
|
||||
details := map[string]any{
|
||||
"type": "resolution",
|
||||
"subtype": status,
|
||||
"entityType": entityType,
|
||||
"query": query,
|
||||
"candidates": candidates,
|
||||
}
|
||||
if profile := profilectx.Get(); profile != "" {
|
||||
details["profile"] = profile
|
||||
}
|
||||
if status == StatusNotFound {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("没有找到与 %q 唯一匹配且可用于当前操作的%s", query, entityLabel(entityType)),
|
||||
apperrors.WithReason("resolution_not_found"),
|
||||
apperrors.WithOrigin("client"),
|
||||
apperrors.WithFailureStage("target_resolution"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithHint("请提供更完整的名称或直接传稳定 ID"),
|
||||
apperrors.WithDetails(details),
|
||||
)
|
||||
}
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("%q 匹配到多个%s:%s;请提供更精确的名称或直接传稳定 ID", query, entityLabel(entityType), candidateLabels(candidates)),
|
||||
apperrors.WithReason("resolution_ambiguous"),
|
||||
apperrors.WithOrigin("client"),
|
||||
apperrors.WithFailureStage("target_resolution"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithHint("禁止默认选择第一个候选"),
|
||||
apperrors.WithDetails(details),
|
||||
)
|
||||
}
|
||||
|
||||
func newIncompleteChatResolutionError(query string, candidates []Chat, cause string) error {
|
||||
details := map[string]any{
|
||||
"type": "resolution",
|
||||
"subtype": StatusIncomplete,
|
||||
"entityType": "chat",
|
||||
"query": query,
|
||||
"candidates": candidates,
|
||||
"cause": cause,
|
||||
}
|
||||
if profile := profilectx.Get(); profile != "" {
|
||||
details["profile"] = profile
|
||||
}
|
||||
return apperrors.NewAPI(
|
||||
fmt.Sprintf("群名 %q 的候选未能完整读取,已停止后续操作", query),
|
||||
apperrors.WithReason("resolution_incomplete"),
|
||||
apperrors.WithOrigin("mcp_gateway"),
|
||||
apperrors.WithFailureStage("target_resolution"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(true),
|
||||
apperrors.WithHint("请重试,或直接传 openConversationId 跳过群名解析"),
|
||||
apperrors.WithDetails(details),
|
||||
)
|
||||
}
|
||||
|
||||
func newIncompleteUserResolutionError(query string, candidates []User, cause string) error {
|
||||
details := map[string]any{
|
||||
"type": "resolution",
|
||||
"subtype": StatusIncomplete,
|
||||
"entityType": "user",
|
||||
"query": query,
|
||||
"candidates": candidates,
|
||||
"cause": cause,
|
||||
}
|
||||
if profile := profilectx.Get(); profile != "" {
|
||||
details["profile"] = profile
|
||||
}
|
||||
return apperrors.NewAPI(
|
||||
fmt.Sprintf("用户 %q 的候选未能完整读取,已停止后续操作", query),
|
||||
apperrors.WithReason("resolution_incomplete"),
|
||||
apperrors.WithOrigin("mcp_gateway"),
|
||||
apperrors.WithFailureStage("target_resolution"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(true),
|
||||
apperrors.WithHint("请重试,或直接传 userId/openDingTalkId 跳过姓名解析"),
|
||||
apperrors.WithDetails(details),
|
||||
)
|
||||
}
|
||||
|
||||
func batchResolutionError(entityType string, failures []map[string]any) error {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("%d 个%s目标未能唯一解析;已停止后续操作", len(failures), entityLabel(entityType)),
|
||||
apperrors.WithReason("resolution_batch_failed"),
|
||||
apperrors.WithOrigin("client"),
|
||||
apperrors.WithFailureStage("target_resolution"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithHint("请逐项消歧或直接传稳定 ID"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"type": "resolution",
|
||||
"subtype": "batch_failed",
|
||||
"entityType": entityType,
|
||||
"failures": failures,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func resolutionDetails(err error) (map[string]any, bool) {
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) || typed.Details["type"] != "resolution" {
|
||||
return nil, false
|
||||
}
|
||||
switch typed.Reason {
|
||||
case "resolution_not_found", "resolution_ambiguous":
|
||||
// These are client-side disambiguation outcomes and may be combined
|
||||
// into one batch response. Incomplete/API/auth failures must retain
|
||||
// their original category and retryability and stop immediately.
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
return typed.Details, true
|
||||
}
|
||||
|
||||
func entityLabel(entityType string) string {
|
||||
if entityType == "chat" {
|
||||
return "群聊"
|
||||
}
|
||||
return "用户"
|
||||
}
|
||||
|
||||
func candidateLabels(candidates any) string {
|
||||
switch values := candidates.(type) {
|
||||
case []User:
|
||||
return strings.Join(UserLabels(values), "、")
|
||||
case []Chat:
|
||||
return strings.Join(ChatLabels(values), "、")
|
||||
default:
|
||||
return "候选"
|
||||
}
|
||||
}
|
||||
|
||||
func firstList(data map[string]any, keys ...string) []any {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
scopes := []map[string]any{data}
|
||||
for _, wrapper := range []string{"result", "data"} {
|
||||
if nested, ok := data[wrapper].(map[string]any); ok {
|
||||
scopes = append(scopes, nested)
|
||||
}
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
for _, key := range keys {
|
||||
if raw, ok := scope[key].([]any); ok {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringValue(row map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := row[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func uniqueQueries(queries []string) []string {
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, len(queries))
|
||||
for _, query := range queries {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" || seen[query] {
|
||||
continue
|
||||
}
|
||||
seen[query] = true
|
||||
result = append(result, query)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package targetresolver
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/profilectx"
|
||||
)
|
||||
|
||||
type resolverReaderFunc func(product, tool string, params map[string]any) (map[string]any, error)
|
||||
|
||||
func (f resolverReaderFunc) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
|
||||
return f(product, tool, params)
|
||||
}
|
||||
|
||||
type chatResolutionReader struct {
|
||||
responses []map[string]any
|
||||
calls []map[string]any
|
||||
}
|
||||
|
||||
func (r *chatResolutionReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
|
||||
r.calls = append(r.calls, params)
|
||||
if product != "im" || tool != "search_groups" {
|
||||
return nil, stderrors.New("unexpected resolver tool")
|
||||
}
|
||||
if len(r.calls) > len(r.responses) {
|
||||
return nil, stderrors.New("unexpected resolver page")
|
||||
}
|
||||
return r.responses[len(r.calls)-1], nil
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExtractUsersKeepsUsableExternalContacts(t *testing.T) {
|
||||
users := ExtractUsers(map[string]any{
|
||||
"result": []any{
|
||||
map[string]any{"userId": "u1", "openDingTalkId": "D1", "name": "张三"},
|
||||
map[string]any{"openDingtalkId": "D2", "nick": "外部张三"},
|
||||
map[string]any{"name": "无 ID"},
|
||||
"garbage",
|
||||
},
|
||||
})
|
||||
if len(users) != 2 {
|
||||
t.Fatalf("users = %#v", users)
|
||||
}
|
||||
if users[1].OpenDingTalkID != "D2" || users[1].Name != "外部张三" {
|
||||
t.Fatalf("external user = %#v", users[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExtractUsersAcceptsEnterprisePersonMetadata(t *testing.T) {
|
||||
users := ExtractUsers(map[string]any{
|
||||
"result": []any{
|
||||
map[string]any{
|
||||
"meta": map[string]any{
|
||||
"staffId": "u1",
|
||||
"openDingTalkId": "D1",
|
||||
"name": "柏荣",
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"userId": "u2",
|
||||
"openDingTalkId": "D2",
|
||||
"title": "展示名",
|
||||
},
|
||||
},
|
||||
})
|
||||
if !reflect.DeepEqual(users, []User{
|
||||
{UserID: "u1", OpenDingTalkID: "D1", Name: "柏荣"},
|
||||
{UserID: "u2", OpenDingTalkID: "D2", Name: "展示名"},
|
||||
}) {
|
||||
t.Fatalf("enterprise users = %#v", users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveEnterpriseUserUsesCalibratedNameSearch(t *testing.T) {
|
||||
reader := resolverReaderFunc(func(product, tool string, params map[string]any) (map[string]any, error) {
|
||||
if product != "aisearch" || tool != "enterprise_person_search" {
|
||||
t.Fatalf("tool = %s/%s", product, tool)
|
||||
}
|
||||
if params["keyword"] != "柏荣" || !reflect.DeepEqual(params["dimension"], []string{"name"}) {
|
||||
t.Fatalf("params = %#v", params)
|
||||
}
|
||||
return map[string]any{"result": []any{map[string]any{
|
||||
"userId": "u1",
|
||||
"openDingTalkId": "D1",
|
||||
"meta": map[string]any{"name": "柏荣"},
|
||||
}}}, nil
|
||||
})
|
||||
resolved, err := ResolveEnterpriseUser(reader, " 柏荣 ", IdentityAny)
|
||||
if err != nil || resolved.MatchType != "exact" || resolved.Selected.UserID != "u1" {
|
||||
t.Fatalf("resolved = %#v, err = %v", resolved, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUserResolutionFailsClosedOnUnpageableContinuation(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
resolve func(Reader) (UserResolution, error)
|
||||
data map[string]any
|
||||
}{
|
||||
{
|
||||
name: "contact search",
|
||||
resolve: func(reader Reader) (UserResolution, error) {
|
||||
return ResolveUser(reader, "张三", IdentityAny)
|
||||
},
|
||||
data: map[string]any{
|
||||
"result": []any{map[string]any{"userId": "u1", "name": "张三"}},
|
||||
"hasMore": true,
|
||||
"nextCursor": "page-2",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "enterprise search",
|
||||
resolve: func(reader Reader) (UserResolution, error) {
|
||||
return ResolveEnterpriseUser(reader, "张三", IdentityAny)
|
||||
},
|
||||
data: map[string]any{
|
||||
"result": []any{map[string]any{"userId": "u1", "name": "张三"}},
|
||||
"nextCursor": "page-2",
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := tc.resolve(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
return tc.data, nil
|
||||
}))
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) || typed.Reason != "resolution_incomplete" || !typed.Retryable {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
if typed.Details["entityType"] != "user" || typed.Details["subtype"] != StatusIncomplete {
|
||||
t.Fatalf("details = %#v", typed.Details)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fullPage := make([]any, userSearchDefaultResultLimit)
|
||||
fullPage[0] = map[string]any{
|
||||
"openDingTalkId": "D1",
|
||||
"name": "张三",
|
||||
}
|
||||
for i := 1; i < len(fullPage); i++ {
|
||||
// These first-page contacts do not satisfy the downstream open-ID
|
||||
// requirement. A second-page namesake still could, so the one usable
|
||||
// first-page candidate must not be treated as globally unique.
|
||||
fullPage[i] = map[string]any{
|
||||
"userId": fmt.Sprintf("u%d", i),
|
||||
"name": fmt.Sprintf("张三候选%d", i),
|
||||
}
|
||||
}
|
||||
_, err := ResolveUser(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
return map[string]any{"result": fullPage}, nil
|
||||
}), "张三", IdentityOpenDingTalkID)
|
||||
var fullPageError *apperrors.Error
|
||||
if !stderrors.As(err, &fullPageError) || fullPageError.Reason != "resolution_incomplete" {
|
||||
t.Fatalf("full-page error = %#v", err)
|
||||
}
|
||||
if cause := fmt.Sprint(fullPageError.Details["cause"]); !strings.Contains(cause, "满额 20 条") {
|
||||
t.Fatalf("full-page cause = %q", cause)
|
||||
}
|
||||
|
||||
terminalCursor := map[string]any{
|
||||
"result": []any{map[string]any{"userId": "u1", "name": "张三"}},
|
||||
"hasMore": false,
|
||||
"nextCursor": "terminal-token",
|
||||
}
|
||||
resolved, err := ResolveUser(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
return terminalCursor, nil
|
||||
}), "张三", IdentityAny)
|
||||
if err != nil || resolved.Selected.UserID != "u1" {
|
||||
t.Fatalf("terminal cursor resolved = %#v, err = %v", resolved, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOpenConversationIDIsNeverSearchedAsAGroupName(t *testing.T) {
|
||||
for _, value := range []string{"cid-fixture-chat-0001", " CIDO123456789 "} {
|
||||
if !LooksLikeOpenConversationID(value) {
|
||||
t.Fatalf("LooksLikeOpenConversationID(%q) = false", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"cid", "项目cid群", "conversation-1"} {
|
||||
if LooksLikeOpenConversationID(value) {
|
||||
t.Fatalf("LooksLikeOpenConversationID(%q) = true", value)
|
||||
}
|
||||
}
|
||||
|
||||
reader := &chatResolutionReader{}
|
||||
_, err := ResolveChat(reader, "cid-fixture-chat-0001")
|
||||
var typed *apperrors.Error
|
||||
if err == nil || !stderrors.As(err, &typed) || typed.Reason != "target_type_mismatch" {
|
||||
t.Fatalf("ResolveChat(stable id) error = %v", err)
|
||||
}
|
||||
if strings.Contains(typed.Message, "看起来是") || !strings.Contains(typed.Message, "群目标参数类型不匹配") {
|
||||
t.Fatalf("ResolveChat(stable id) message = %q", typed.Message)
|
||||
}
|
||||
if typed.Details["providedType"] != "openConversationId" || typed.Details["expectedType"] != "chatName" {
|
||||
t.Fatalf("ResolveChat(stable id) details = %#v", typed.Details)
|
||||
}
|
||||
if len(reader.calls) != 0 {
|
||||
t.Fatalf("stable id unexpectedly reached search: %#v", reader.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveChatTargetStableIDBypassesSearch(t *testing.T) {
|
||||
reader := &chatResolutionReader{}
|
||||
resolved, err := ResolveChatTarget(reader, " cid-fixture-chat-0001 ", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved.Selected.OpenConversationID != "cid-fixture-chat-0001" || resolved.MatchType != "stable_id" {
|
||||
t.Fatalf("resolved = %#v", resolved)
|
||||
}
|
||||
if len(reader.calls) != 0 {
|
||||
t.Fatalf("stable id unexpectedly reached search: %#v", reader.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveChatTargetNaturalDirectValueUsesResolver(t *testing.T) {
|
||||
reader := &chatResolutionReader{responses: []map[string]any{{
|
||||
"result": []any{map[string]any{"openConversationId": "cid-project-1", "title": "项目群"}},
|
||||
"hasMore": false,
|
||||
}}}
|
||||
resolved, err := ResolveChatTarget(reader, "项目群", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved.Selected.OpenConversationID != "cid-project-1" || len(reader.calls) != 1 {
|
||||
t.Fatalf("resolved = %#v calls = %#v", resolved, reader.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveChatTargetQueryStableIDAlsoBypassesSearch(t *testing.T) {
|
||||
reader := &chatResolutionReader{}
|
||||
resolved, err := ResolveChatTarget(reader, "", "cid-query-123456")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved.Selected.OpenConversationID != "cid-query-123456" || len(reader.calls) != 0 {
|
||||
t.Fatalf("resolved = %#v calls = %#v", resolved, reader.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUserSelectionDedupesButDoesNotHideNamesakes(t *testing.T) {
|
||||
users := dedupeUsers([]User{
|
||||
{UserID: "u1", OpenDingTalkID: "D1", Name: "张三"},
|
||||
{UserID: "u1", OpenDingTalkID: "D1", Name: "duplicate"},
|
||||
{UserID: "u2", OpenDingTalkID: "D2", Name: "张三丰"},
|
||||
})
|
||||
selected, matchType := selectUsers(users, " 张三 ")
|
||||
if len(selected) != 2 || matchType != "ambiguous" {
|
||||
t.Fatalf("selected = %#v, matchType = %q", selected, matchType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSelectionKeepsMultipleExactMatchesAmbiguous(t *testing.T) {
|
||||
chats := dedupeChats([]Chat{
|
||||
{OpenConversationID: "c1", Name: "项目群"},
|
||||
{OpenConversationID: "c1", Name: "项目群"},
|
||||
{OpenConversationID: "c2", Name: "项目群"},
|
||||
{OpenConversationID: "c3", Name: "项目群-归档"},
|
||||
})
|
||||
selected, matchType := preferExactChats(chats, "项目群")
|
||||
if len(selected) != 2 || matchType != "exact" {
|
||||
t.Fatalf("selected = %#v, matchType = %q", selected, matchType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveChatPagesBeforeApplyingExactPreference(t *testing.T) {
|
||||
reader := &chatResolutionReader{responses: []map[string]any{
|
||||
{
|
||||
"result": []any{
|
||||
map[string]any{"openConversationId": "archive", "title": "项目群-归档"},
|
||||
},
|
||||
"hasMore": true,
|
||||
"nextCursor": "page-2",
|
||||
},
|
||||
{
|
||||
"result": []any{
|
||||
map[string]any{"openConversationId": "active", "title": "项目群"},
|
||||
},
|
||||
"hasMore": false,
|
||||
},
|
||||
}}
|
||||
resolved, err := ResolveChat(reader, "项目群")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved.Selected.OpenConversationID != "active" || resolved.MatchType != "exact" {
|
||||
t.Fatalf("resolved = %#v", resolved)
|
||||
}
|
||||
wantCursors := []any{"0", "page-2"}
|
||||
gotCursors := []any{reader.calls[0]["cursor"], reader.calls[1]["cursor"]}
|
||||
if !reflect.DeepEqual(gotCursors, wantCursors) {
|
||||
t.Fatalf("cursors = %#v, want %#v", gotCursors, wantCursors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveChatKeepsExactNamesakesAcrossPagesAmbiguous(t *testing.T) {
|
||||
reader := &chatResolutionReader{responses: []map[string]any{
|
||||
{
|
||||
"result": []any{
|
||||
map[string]any{"openConversationId": "c1", "title": "项目群"},
|
||||
},
|
||||
"hasMore": true,
|
||||
"nextCursor": "page-2",
|
||||
},
|
||||
{
|
||||
"result": map[string]any{
|
||||
"items": []any{
|
||||
map[string]any{"openConversationId": "c2", "title": "项目群"},
|
||||
},
|
||||
"hasMore": false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, err := ResolveChat(reader, "项目群")
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) || typed.Reason != "resolution_ambiguous" {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
candidates, ok := typed.Details["candidates"].([]Chat)
|
||||
if !ok || len(candidates) != 2 {
|
||||
t.Fatalf("details = %#v", typed.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveChatFailsClosedWhenPaginationCannotAdvance(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
nextCursor any
|
||||
wantFragment string
|
||||
}{
|
||||
{name: "missing cursor", wantFragment: "没有返回可继续"},
|
||||
{name: "stalled cursor", nextCursor: "0", wantFragment: "游标停滞"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
response := map[string]any{
|
||||
"result": []any{
|
||||
map[string]any{"openConversationId": "c1", "title": "项目群"},
|
||||
},
|
||||
"hasMore": true,
|
||||
}
|
||||
if tc.nextCursor != nil {
|
||||
response["nextCursor"] = tc.nextCursor
|
||||
}
|
||||
reader := &chatResolutionReader{responses: []map[string]any{response}}
|
||||
_, err := ResolveChat(reader, "项目群")
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) || typed.Reason != "resolution_incomplete" || !typed.Retryable {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
if typed.Details["subtype"] != StatusIncomplete {
|
||||
t.Fatalf("details = %#v", typed.Details)
|
||||
}
|
||||
if typed.Origin != "mcp_gateway" || typed.FailureStage != "target_resolution" || typed.ExecutionStarted == nil || *typed.ExecutionStarted {
|
||||
t.Fatalf("failure semantics = origin %q stage %q execution_started %v", typed.Origin, typed.FailureStage, typed.ExecutionStarted)
|
||||
}
|
||||
cause, _ := typed.Details["cause"].(string)
|
||||
if cause == "" || !strings.Contains(cause, tc.wantFragment) {
|
||||
t.Fatalf("cause = %q, want fragment %q", cause, tc.wantFragment)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveChatAcceptsShortLegacyPageWithoutPaginationMetadata(t *testing.T) {
|
||||
reader := &chatResolutionReader{responses: []map[string]any{{
|
||||
"result": []any{
|
||||
map[string]any{"openConversationId": "c1", "title": "项目群"},
|
||||
},
|
||||
}}}
|
||||
resolved, err := ResolveChat(reader, "项目群")
|
||||
if err != nil || resolved.Selected.OpenConversationID != "c1" {
|
||||
t.Fatalf("resolved = %#v, err = %v", resolved, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolutionErrorCarriesStructuredCandidates(t *testing.T) {
|
||||
err := newResolutionError(StatusAmbiguous, "chat", "项目群", []Chat{
|
||||
{OpenConversationID: "c1", Name: "项目群"},
|
||||
{OpenConversationID: "c2", Name: "项目群"},
|
||||
})
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) {
|
||||
t.Fatalf("error type = %T", err)
|
||||
}
|
||||
if typed.Reason != "resolution_ambiguous" {
|
||||
t.Fatalf("reason = %q", typed.Reason)
|
||||
}
|
||||
if typed.Origin != "client" || typed.FailureStage != "target_resolution" || typed.ExecutionStarted == nil || *typed.ExecutionStarted {
|
||||
t.Fatalf("failure semantics = origin %q stage %q execution_started %v", typed.Origin, typed.FailureStage, typed.ExecutionStarted)
|
||||
}
|
||||
if typed.Details["type"] != "resolution" || typed.Details["subtype"] != StatusAmbiguous {
|
||||
t.Fatalf("details = %#v", typed.Details)
|
||||
}
|
||||
candidates, ok := typed.Details["candidates"].([]Chat)
|
||||
if !ok || len(candidates) != 2 {
|
||||
t.Fatalf("candidates = %#v", typed.Details["candidates"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolverCompletionBranches(t *testing.T) {
|
||||
t.Run("user transport and selection", func(t *testing.T) {
|
||||
upstream := stderrors.New("upstream")
|
||||
if _, err := ResolveUser(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
return nil, upstream
|
||||
}), "张三", IdentityAny); !stderrors.Is(err, upstream) {
|
||||
t.Fatalf("transport error = %v", err)
|
||||
}
|
||||
|
||||
reader := resolverReaderFunc(func(_ string, _ string, params map[string]any) (map[string]any, error) {
|
||||
switch params["keyword"] {
|
||||
case "missing":
|
||||
return map[string]any{"result": []any{}}, nil
|
||||
case "external":
|
||||
return map[string]any{"result": []any{map[string]any{"openDingTalkId": "D1", "name": "外部"}}}, nil
|
||||
default:
|
||||
return map[string]any{"result": []any{map[string]any{"userId": "u1", "openDingTalkId": "D1", "name": "张三"}}}, nil
|
||||
}
|
||||
})
|
||||
if _, err := ResolveUser(reader, "missing", IdentityAny); err == nil {
|
||||
t.Fatal("missing user unexpectedly resolved")
|
||||
}
|
||||
if resolved, err := ResolveUser(reader, "张三", IdentityUserID); err != nil || resolved.MatchType != "exact" {
|
||||
t.Fatalf("exact user = %#v, %v", resolved, err)
|
||||
}
|
||||
if resolved, err := ResolveUser(reader, "external", IdentityOpenDingTalkID); err != nil || resolved.Selected.OpenDingTalkID != "D1" {
|
||||
t.Fatalf("external user = %#v, %v", resolved, err)
|
||||
}
|
||||
ambiguous := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
return map[string]any{"result": []any{
|
||||
map[string]any{"userId": "u1", "name": "甲"},
|
||||
map[string]any{"userId": "u2", "name": "乙"},
|
||||
}}, nil
|
||||
})
|
||||
if _, err := ResolveUser(ambiguous, "用户", IdentityAny); err == nil {
|
||||
t.Fatal("ambiguous user unexpectedly resolved")
|
||||
}
|
||||
if _, err := ResolveEnterpriseUser(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
return nil, upstream
|
||||
}), "张三", IdentityAny); !stderrors.Is(err, upstream) {
|
||||
t.Fatalf("enterprise transport error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("chat transport metadata and page limit", func(t *testing.T) {
|
||||
upstream := stderrors.New("upstream")
|
||||
if _, err := ResolveChat(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
return nil, upstream
|
||||
}), "群"); !stderrors.Is(err, upstream) {
|
||||
t.Fatalf("transport error = %v", err)
|
||||
}
|
||||
|
||||
fullPage := make([]any, chatResolutionPageSize)
|
||||
for i := range fullPage {
|
||||
fullPage[i] = map[string]any{"openConversationId": fmt.Sprintf("c%d", i), "title": fmt.Sprintf("群%d", i)}
|
||||
}
|
||||
if _, err := ResolveChat(&chatResolutionReader{responses: []map[string]any{{"result": fullPage}}}, "群"); err == nil {
|
||||
t.Fatal("full page without continuation unexpectedly resolved")
|
||||
}
|
||||
|
||||
pages := 0
|
||||
limitReader := resolverReaderFunc(func(_ string, _ string, _ map[string]any) (map[string]any, error) {
|
||||
pages++
|
||||
return map[string]any{
|
||||
"result": []any{map[string]any{"openConversationId": fmt.Sprintf("c%d", pages), "title": "群"}},
|
||||
"hasMore": true,
|
||||
"nextCursor": fmt.Sprintf("page-%d", pages),
|
||||
}, nil
|
||||
})
|
||||
if _, err := ResolveChat(limitReader, "群"); err == nil || pages != chatResolutionPageLimit {
|
||||
t.Fatalf("page limit error = %v, pages=%d", err, pages)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("target and pagination validation", func(t *testing.T) {
|
||||
reader := &chatResolutionReader{}
|
||||
if _, err := ResolveChatTarget(reader, "cid-123456789", "群"); err == nil {
|
||||
t.Fatal("conflicting targets unexpectedly resolved")
|
||||
}
|
||||
if _, err := ResolveChatTarget(reader, "", ""); err == nil {
|
||||
t.Fatal("missing target unexpectedly resolved")
|
||||
}
|
||||
if page := extractChatPagination(nil); page.hasMoreKnown || page.nextCursor != "" {
|
||||
t.Fatalf("nil pagination = %#v", page)
|
||||
}
|
||||
page := extractChatPagination(map[string]any{"data": map[string]any{"has_more": true, "next_token": 42}})
|
||||
if !page.hasMoreKnown || !page.hasMore || page.nextCursor != "42" {
|
||||
t.Fatalf("nested pagination = %#v", page)
|
||||
}
|
||||
for _, value := range []any{nil, "", "<nil>"} {
|
||||
if got := paginationString(value); got != "" {
|
||||
t.Fatalf("paginationString(%#v) = %q", value, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch resolution", func(t *testing.T) {
|
||||
reader := resolverReaderFunc(func(product, _ string, params map[string]any) (map[string]any, error) {
|
||||
query := fmt.Sprint(params["keyword"])
|
||||
if query == "upstream" {
|
||||
return nil, stderrors.New("upstream")
|
||||
}
|
||||
if product == "contact" {
|
||||
if query == "missing" {
|
||||
return map[string]any{"result": []any{}}, nil
|
||||
}
|
||||
return map[string]any{"result": []any{map[string]any{"userId": "u1", "name": query}}}, nil
|
||||
}
|
||||
if query == "missing" {
|
||||
return map[string]any{"result": []any{}, "hasMore": false}, nil
|
||||
}
|
||||
return map[string]any{"result": []any{map[string]any{"openConversationId": "cid-shared-123", "title": query}}, "hasMore": false}, nil
|
||||
})
|
||||
if users, err := ResolveUsers(reader, []string{"张三", " 张三 "}, IdentityUserID); err != nil || len(users) != 1 {
|
||||
t.Fatalf("deduped users = %#v, %v", users, err)
|
||||
}
|
||||
if _, err := ResolveUsers(reader, []string{"missing"}, IdentityUserID); err == nil {
|
||||
t.Fatal("batch missing user unexpectedly resolved")
|
||||
}
|
||||
if _, err := ResolveUsers(reader, []string{"upstream"}, IdentityUserID); err == nil {
|
||||
t.Fatal("batch user transport error missing")
|
||||
}
|
||||
if chats, err := ResolveChats(reader, []string{"群一", "群二"}); err != nil || len(chats) != 1 {
|
||||
t.Fatalf("deduped chats = %#v, %v", chats, err)
|
||||
}
|
||||
if _, err := ResolveChats(reader, []string{"missing"}); err == nil {
|
||||
t.Fatal("batch missing chat unexpectedly resolved")
|
||||
}
|
||||
if _, err := ResolveChats(reader, []string{"upstream"}); err == nil {
|
||||
t.Fatal("batch chat transport error missing")
|
||||
}
|
||||
|
||||
assertIncomplete := func(label string, err error) {
|
||||
t.Helper()
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) || typed.Category != apperrors.CategoryAPI ||
|
||||
typed.Reason != "resolution_incomplete" || !typed.Retryable {
|
||||
t.Fatalf("%s error = %#v", label, err)
|
||||
}
|
||||
}
|
||||
fullUsers := make([]any, userSearchDefaultResultLimit)
|
||||
for i := range fullUsers {
|
||||
fullUsers[i] = map[string]any{"userId": fmt.Sprintf("u%d", i), "name": "张三"}
|
||||
}
|
||||
_, err := ResolveUsers(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
return map[string]any{"result": fullUsers}, nil
|
||||
}), []string{"张三", "李四"}, IdentityUserID)
|
||||
assertIncomplete("batch user", err)
|
||||
|
||||
fullChats := make([]any, chatResolutionPageSize)
|
||||
for i := range fullChats {
|
||||
fullChats[i] = map[string]any{
|
||||
"openConversationId": fmt.Sprintf("cid-%d", i),
|
||||
"title": "项目群",
|
||||
}
|
||||
}
|
||||
_, err = ResolveChats(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
return map[string]any{"result": fullChats}, nil
|
||||
}), []string{"项目群", "研发群"})
|
||||
assertIncomplete("batch chat", err)
|
||||
})
|
||||
|
||||
t.Run("public projections and internal helpers", func(t *testing.T) {
|
||||
users := ExtractUsers(map[string]any{"items": []any{"bad", map[string]any{"name": "missing"}, map[string]any{"orgUserId": "u1", "staffName": "张三"}}})
|
||||
if got := UsersWithUserID(users); len(got) != 1 {
|
||||
t.Fatalf("users = %#v", got)
|
||||
}
|
||||
chats := ExtractChats(map[string]any{"groups": []any{"bad", map[string]any{"title": "missing"}, map[string]any{"id": "c1"}}})
|
||||
if len(chats) != 1 || len(PreferExactChats(chats, "none")) != 1 {
|
||||
t.Fatalf("chats = %#v", chats)
|
||||
}
|
||||
if labels := UserLabels([]User{{OpenDingTalkID: "D1", Name: "外部"}}); !reflect.DeepEqual(labels, []string{"外部(D1)"}) {
|
||||
t.Fatalf("user labels = %#v", labels)
|
||||
}
|
||||
if labels := ChatLabels([]Chat{{OpenConversationID: "c1"}}); !strings.Contains(labels[0], "未命名") {
|
||||
t.Fatalf("chat labels = %#v", labels)
|
||||
}
|
||||
if _, ok := resolutionDetails(stderrors.New("plain")); ok {
|
||||
t.Fatal("plain error reported resolution details")
|
||||
}
|
||||
if label := candidateLabels(1); label != "候选" {
|
||||
t.Fatalf("candidate label = %q", label)
|
||||
}
|
||||
if label := candidateLabels([]User{{UserID: "u1", Name: "甲"}}); label != "甲(u1)" {
|
||||
t.Fatalf("user candidate label = %q", label)
|
||||
}
|
||||
if firstList(nil, "result") != nil {
|
||||
t.Fatal("nil firstList should return nil")
|
||||
}
|
||||
if firstList(map[string]any{"result": "not-a-list"}, "result") != nil {
|
||||
t.Fatal("missing firstList should return nil")
|
||||
}
|
||||
if got := filterUsersByIdentity([]User{{OpenDingTalkID: "D1"}}, IdentityUserID); len(got) != 0 {
|
||||
t.Fatalf("user-id filter = %#v", got)
|
||||
}
|
||||
if got := filterUsersByIdentity([]User{{UserID: "u1"}}, IdentityOpenDingTalkID); len(got) != 0 {
|
||||
t.Fatalf("open-id filter = %#v", got)
|
||||
}
|
||||
|
||||
profilectx.Set("fixture")
|
||||
t.Cleanup(func() { profilectx.Set("") })
|
||||
for _, err := range []error{
|
||||
newResolutionError(StatusNotFound, "user", "missing", nil),
|
||||
newIncompleteChatResolutionError("群", nil, "fixture"),
|
||||
newIncompleteUserResolutionError("用户", nil, "fixture"),
|
||||
} {
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) || typed.Details["profile"] != "fixture" {
|
||||
t.Fatalf("profile details = %#v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch deduplicates shared open ids", func(t *testing.T) {
|
||||
reader := resolverReaderFunc(func(_ string, _ string, params map[string]any) (map[string]any, error) {
|
||||
query := fmt.Sprint(params["keyword"])
|
||||
return map[string]any{"result": []any{map[string]any{
|
||||
"userId": "user-" + query,
|
||||
"openDingTalkId": "D-shared",
|
||||
"name": query,
|
||||
}}}, nil
|
||||
})
|
||||
resolved, err := ResolveUsers(reader, []string{"甲", "乙"}, IdentityAny)
|
||||
if err != nil || len(resolved) != 1 {
|
||||
t.Fatalf("shared open-id result = %#v, %v", resolved, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -81,6 +81,8 @@ const (
|
||||
type Flag struct {
|
||||
// Name is the long flag name (kebab-case), e.g. "user-ids".
|
||||
Name string `json:"name"`
|
||||
// Shorthand is the optional one-character CLI spelling, e.g. "o" for --output.
|
||||
Shorthand string `json:"shorthand,omitempty"`
|
||||
// Type is the value type; defaults to FlagString when empty.
|
||||
Type FlagType `json:"type"`
|
||||
// Default is the default value rendered as a string.
|
||||
@@ -135,6 +137,16 @@ type Shortcut struct {
|
||||
Service string
|
||||
// Command is the leaf name including its "+" prefix, e.g. "+search-user".
|
||||
Command string
|
||||
// Aliases are hidden, compatibility-only Cobra spellings for the same
|
||||
// command identity. Agent-facing Skill and Schema examples must continue to
|
||||
// use Command; reviewed Schema aliases are declared separately in the
|
||||
// CommandRegistry.
|
||||
Aliases []string
|
||||
// SinglePositionalAliasFor optionally treats one positional argument as the
|
||||
// value of the named string flag. It is intended only for unambiguous search
|
||||
// compatibility such as `+chat-search "群名"`; it never changes the
|
||||
// canonical flag-based contract published to Agents.
|
||||
SinglePositionalAliasFor string
|
||||
// Product is the canonical MCP product id used to build the invocation.
|
||||
// Defaults to Service when empty.
|
||||
Product string
|
||||
|
||||
@@ -24,7 +24,10 @@ import gen_shortcut_comparison as shortcut_source # noqa: E402
|
||||
|
||||
CATALOG_PATH = ROOT / "docs" / "shortcut-public-catalog.json"
|
||||
MONO_SKILL = ROOT / "skills" / "mono" / "SKILL.md"
|
||||
|
||||
SHARED_SKILL = ROOT / "skills" / "multi" / "dws-shared" / "SKILL.md"
|
||||
RUNTIME_CONTRACT_SOURCE = (
|
||||
ROOT / "skills" / "multi" / "dws-shared" / "references" / "runtime-contract.md"
|
||||
)
|
||||
SERVICE_TO_SKILL = {
|
||||
"aitable": ROOT / "skills" / "multi" / "dingtalk-aitable" / "SKILL.md",
|
||||
"attendance": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "attendance.md",
|
||||
@@ -48,6 +51,8 @@ MONO_START = "<!-- VISIBLE_SHORTCUTS_OVERVIEW_START -->"
|
||||
MONO_END = "<!-- VISIBLE_SHORTCUTS_OVERVIEW_END -->"
|
||||
PRODUCT_START = "<!-- VISIBLE_SHORTCUTS_START -->"
|
||||
PRODUCT_END = "<!-- VISIBLE_SHORTCUTS_END -->"
|
||||
RUNTIME_CONTRACT_START = "<!-- DWS_RUNTIME_CONTRACT_START -->"
|
||||
RUNTIME_CONTRACT_END = "<!-- DWS_RUNTIME_CONTRACT_END -->"
|
||||
|
||||
# Large, high-frequency product skills should route known intents directly and
|
||||
# keep their full shortcut inventory in Runtime Catalog/Schema. Add services
|
||||
@@ -92,6 +97,29 @@ def replace_block(text: str, start: str, end: str, block: str, fallback_anchor:
|
||||
return text.replace(fallback_anchor, block + "\n\n" + fallback_anchor, 1)
|
||||
|
||||
|
||||
def replace_required_block(text: str, start: str, end: str, block: str) -> str:
|
||||
if text.count(start) != 1 or text.count(end) != 1:
|
||||
raise RuntimeError(
|
||||
f"expected exactly one generated block {start!r} ... {end!r}"
|
||||
)
|
||||
before = text.split(start, 1)[0]
|
||||
after = text.split(end, 1)[1]
|
||||
return before + block + after
|
||||
|
||||
|
||||
def runtime_contract_block() -> str:
|
||||
contract = RUNTIME_CONTRACT_SOURCE.read_text(encoding="utf-8").strip()
|
||||
if not contract.startswith("## 最小 DWS 执行契约"):
|
||||
raise RuntimeError(
|
||||
f"runtime contract must start with its canonical heading: {RUNTIME_CONTRACT_SOURCE}"
|
||||
)
|
||||
return (
|
||||
f"{RUNTIME_CONTRACT_START}\n"
|
||||
f"{contract}\n"
|
||||
f"{RUNTIME_CONTRACT_END}"
|
||||
)
|
||||
|
||||
|
||||
def mono_overview(items: list[dict[str, Any]]) -> str:
|
||||
counts = Counter(item["service"] for item in items)
|
||||
rows = []
|
||||
@@ -161,6 +189,26 @@ def update_mono(items: list[dict[str, Any]], check: bool) -> list[Path]:
|
||||
return [MONO_SKILL] if apply_update(MONO_SKILL, text, updated, check) else []
|
||||
|
||||
|
||||
def update_runtime_contract(check: bool) -> list[Path]:
|
||||
block = runtime_contract_block()
|
||||
changed = []
|
||||
targets = [
|
||||
ROOT / "skills" / "multi" / "dingtalk-chat" / "SKILL.md",
|
||||
SHARED_SKILL,
|
||||
]
|
||||
for path in targets:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
updated = replace_required_block(
|
||||
text,
|
||||
RUNTIME_CONTRACT_START,
|
||||
RUNTIME_CONTRACT_END,
|
||||
block,
|
||||
)
|
||||
if apply_update(path, text, updated, check):
|
||||
changed.append(path)
|
||||
return changed
|
||||
|
||||
|
||||
def update_product_skills(items: list[dict[str, Any]], check: bool) -> list[Path]:
|
||||
by_service: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for item in items:
|
||||
@@ -190,7 +238,8 @@ def main() -> int:
|
||||
args = parser.parse_args()
|
||||
|
||||
items = collect_visible()
|
||||
changed = update_mono(items, args.check)
|
||||
changed = update_runtime_contract(args.check)
|
||||
changed.extend(update_mono(items, args.check))
|
||||
changed.extend(update_product_skills(items, args.check))
|
||||
if args.check and changed:
|
||||
print("run: python3 scripts/gen_skill_shortcut_sections.py", file=sys.stderr)
|
||||
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
cd "$ROOT"
|
||||
. "$ROOT/scripts/policy/policy-runtime.sh"
|
||||
policy_prepare_runtime "$ROOT"
|
||||
|
||||
TMP_ROOT="$(policy_runtime_mktemp_dir dws-multi-im-skill-chain)"
|
||||
CHECKER="$TMP_ROOT/multi-im-skill-chain"
|
||||
CHECK_HOME="$TMP_ROOT/home"
|
||||
mkdir -p "$CHECK_HOME"
|
||||
trap 'rm -rf "$TMP_ROOT"' EXIT HUP INT TERM
|
||||
|
||||
go build -o "$CHECKER" ./scripts/policy/multi-im-skill-chain
|
||||
HOME="$CHECK_HOME" DWS_LANG=zh "$CHECKER"
|
||||
@@ -7,8 +7,12 @@ cd "$ROOT"
|
||||
python3 scripts/gen_skill_shortcut_sections.py --check
|
||||
|
||||
chat_skill="skills/multi/dingtalk-chat/SKILL.md"
|
||||
event_skill="skills/multi/dingtalk-event/SKILL.md"
|
||||
mono_skill="skills/mono/SKILL.md"
|
||||
chat_max_bytes=14000
|
||||
runtime_contract="skills/multi/dws-shared/references/runtime-contract.md"
|
||||
chat_max_bytes=10000
|
||||
event_max_bytes=10000
|
||||
runtime_contract_max_bytes=3000
|
||||
|
||||
chat_bytes="$(wc -c < "$chat_skill" | tr -d ' ')"
|
||||
if [ "$chat_bytes" -gt "$chat_max_bytes" ]; then
|
||||
@@ -17,6 +21,20 @@ if [ "$chat_bytes" -gt "$chat_max_bytes" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
event_bytes="$(wc -c < "$event_skill" | tr -d ' ')"
|
||||
if [ "$event_bytes" -gt "$event_max_bytes" ]; then
|
||||
printf '%s\n' \
|
||||
"skill context budget exceeded: $event_skill is ${event_bytes} bytes (max ${event_max_bytes})" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
runtime_contract_bytes="$(wc -c < "$runtime_contract" | tr -d ' ')"
|
||||
if [ "$runtime_contract_bytes" -gt "$runtime_contract_max_bytes" ]; then
|
||||
printf '%s\n' \
|
||||
"skill context budget exceeded: $runtime_contract is ${runtime_contract_bytes} bytes (max ${runtime_contract_max_bytes})" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
shortcut_rows="$(
|
||||
awk '
|
||||
/<!-- VISIBLE_SHORTCUTS_START -->/ { in_block = 1; next }
|
||||
@@ -31,6 +49,56 @@ if [ "$shortcut_rows" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for required_heading in \
|
||||
"## 最小 DWS 执行契约" \
|
||||
"## Golden Route" \
|
||||
"## 关键结果语义" \
|
||||
"## 按需加载" \
|
||||
"## 错误最短路径"
|
||||
do
|
||||
if ! grep -Fq "$required_heading" "$chat_skill"; then
|
||||
printf '%s\n' \
|
||||
"skill value regression: $chat_skill is missing $required_heading" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
for required_route in \
|
||||
"+dm" \
|
||||
"+send-to-group" \
|
||||
"+messages-send" \
|
||||
"+chat-messages" \
|
||||
"+search-msg" \
|
||||
"--download-resources" \
|
||||
"+conversation-list-top"
|
||||
do
|
||||
if ! grep -Fq -- "$required_route" "$chat_skill"; then
|
||||
printf '%s\n' \
|
||||
"skill route regression: $chat_skill is missing $required_route" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
for forbidden_route in \
|
||||
"## 标准 SOP" \
|
||||
"dws aisearch person --keyword" \
|
||||
"dws chat message send-by-webhook" \
|
||||
"dt_media_upload" \
|
||||
"MUST 先用 Read"
|
||||
do
|
||||
if grep -Fq "$forbidden_route" "$chat_skill"; then
|
||||
printf '%s\n' \
|
||||
"skill route regression: $chat_skill restored legacy route: $forbidden_route" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if grep -Fq "../dws-shared/SKILL.md" "$chat_skill"; then
|
||||
printf '%s\n' \
|
||||
"skill context regression: $chat_skill requires full dws-shared cold-start loading" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Fq "充分阅读产品参考文件" "$mono_skill"; then
|
||||
printf '%s\n' \
|
||||
"skill context budget regression: $mono_skill requires full product-reference loading" >&2
|
||||
@@ -38,4 +106,4 @@ if grep -Fq "充分阅读产品参考文件" "$mono_skill"; then
|
||||
fi
|
||||
|
||||
printf '%s\n' \
|
||||
"skill context budget: ok (chat_bytes=$chat_bytes max=$chat_max_bytes shortcut_rows=$shortcut_rows)"
|
||||
"skill context budget: ok (chat_bytes=$chat_bytes max=$chat_max_bytes event_bytes=$event_bytes event_max=$event_max_bytes runtime_contract_bytes=$runtime_contract_bytes runtime_contract_max=$runtime_contract_max_bytes shortcut_rows=$shortcut_rows)"
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
// Command multi-im-skill-chain verifies that reviewed high-frequency IM
|
||||
// intents keep one default route across Skill references and Agent selection.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const manifestRelativePath = "scripts/policy/multi-im-skill-chain/testdata/intent_routes.json"
|
||||
|
||||
var (
|
||||
intentIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`)
|
||||
reasonCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
|
||||
intentMarker = regexp.MustCompile(`<!--\s*dws-intent:\s*([a-z0-9][a-z0-9._-]*)\s*-->`)
|
||||
mainGetwd = os.Getwd
|
||||
mainExit = os.Exit
|
||||
markerOpen = os.Open
|
||||
buildEffective = cli.BuildEffectiveCommandRegistry
|
||||
bindEffective = cli.BindEffectiveCommandRegistry
|
||||
)
|
||||
|
||||
type routeManifest struct {
|
||||
Version int `json:"version"`
|
||||
MarkerRoots []string `json:"marker_roots"`
|
||||
Intents []intentRoute `json:"intents"`
|
||||
RetiredScripts []string `json:"retired_scripts"`
|
||||
ContractReference string `json:"contract_reference"`
|
||||
HandoffReference string `json:"handoff_reference"`
|
||||
}
|
||||
|
||||
type intentRoute struct {
|
||||
ID string `json:"id"`
|
||||
PreferredTool string `json:"preferred_tool"`
|
||||
AllowedFallbacks []routeFallback `json:"allowed_fallbacks,omitempty"`
|
||||
ForbiddenDefaultTools []string `json:"forbidden_default_tools,omitempty"`
|
||||
References []string `json:"references"`
|
||||
}
|
||||
|
||||
type routeFallback struct {
|
||||
Tool string `json:"tool"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
}
|
||||
|
||||
type toolFact struct {
|
||||
Canonical string
|
||||
PrimaryPath string
|
||||
Confirmation string
|
||||
UseWhen []string
|
||||
AvoidWhen []string
|
||||
HasMeta bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
rootPath, err := mainGetwd()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
mainExit(2)
|
||||
return
|
||||
}
|
||||
mainExit(run(rootPath, app.NewRootCommand(), os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
func run(rootPath string, root *cobra.Command, stdout, stderr io.Writer) int {
|
||||
manifest, err := loadManifest(filepath.Join(rootPath, manifestRelativePath))
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 2
|
||||
}
|
||||
effective, err := buildEffective(root)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "build effective CommandRegistry: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
bound, err := bindEffective(root, effective)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "bind effective CommandRegistry: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
|
||||
tools := make(map[string]toolFact, len(bound.ByCanonical))
|
||||
for canonical, item := range bound.ByCanonical {
|
||||
meta, ok := cli.ResolveMeta(item.PrimaryCLIPath)
|
||||
tools[canonical] = toolFact{
|
||||
Canonical: canonical,
|
||||
PrimaryPath: item.PrimaryCLIPath,
|
||||
Confirmation: meta.Safety.Confirmation,
|
||||
UseWhen: append([]string(nil), meta.Selection.UseWhen...),
|
||||
AvoidWhen: append([]string(nil), meta.Selection.AvoidWhen...),
|
||||
HasMeta: ok,
|
||||
}
|
||||
}
|
||||
|
||||
failures := validateManifest(rootPath, manifest, tools)
|
||||
sort.Strings(failures)
|
||||
if len(failures) > 0 {
|
||||
fmt.Fprintf(stderr, "multi IM Skill chain check failed (%d problems):\n", len(failures))
|
||||
for _, failure := range failures {
|
||||
fmt.Fprintf(stderr, " - %s\n", failure)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(stdout, "multi IM Skill chain check: ok (%d intents)\n", len(manifest.Intents))
|
||||
return 0
|
||||
}
|
||||
|
||||
func loadManifest(path string) (routeManifest, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return routeManifest{}, fmt.Errorf("read IM intent route manifest: %w", err)
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
||||
decoder.DisallowUnknownFields()
|
||||
var manifest routeManifest
|
||||
if err := decoder.Decode(&manifest); err != nil {
|
||||
return routeManifest{}, fmt.Errorf("decode IM intent route manifest: %w", err)
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func validateManifest(rootPath string, manifest routeManifest, tools map[string]toolFact) []string {
|
||||
var failures []string
|
||||
if manifest.Version != 3 {
|
||||
failures = append(failures, fmt.Sprintf("manifest version = %d, want 3", manifest.Version))
|
||||
}
|
||||
if len(manifest.MarkerRoots) == 0 {
|
||||
failures = append(failures, "manifest marker_roots must not be empty")
|
||||
}
|
||||
if len(manifest.Intents) == 0 {
|
||||
failures = append(failures, "manifest intents must not be empty")
|
||||
}
|
||||
failures = append(failures, validateRetiredScripts(rootPath, manifest.RetiredScripts)...)
|
||||
failures = append(failures, validateTypedContractReference(rootPath, manifest.ContractReference)...)
|
||||
failures = append(failures, validateEventHandoffReference(rootPath, manifest.HandoffReference)...)
|
||||
|
||||
intentByID := make(map[string]intentRoute, len(manifest.Intents))
|
||||
for _, route := range manifest.Intents {
|
||||
if !intentIDPattern.MatchString(route.ID) {
|
||||
failures = append(failures, fmt.Sprintf("intent id %q is invalid", route.ID))
|
||||
continue
|
||||
}
|
||||
if _, exists := intentByID[route.ID]; exists {
|
||||
failures = append(failures, fmt.Sprintf("duplicate intent id %q", route.ID))
|
||||
continue
|
||||
}
|
||||
intentByID[route.ID] = route
|
||||
preferred, ok := tools[route.PreferredTool]
|
||||
if !ok {
|
||||
failures = append(failures, fmt.Sprintf("intent %s preferred tool %q is absent from BoundCommandRegistry", route.ID, route.PreferredTool))
|
||||
continue
|
||||
}
|
||||
if !preferred.HasMeta || preferred.Confirmation == "" {
|
||||
failures = append(failures, fmt.Sprintf("intent %s preferred tool %q is absent from ResolveMeta delivery", route.ID, route.PreferredTool))
|
||||
}
|
||||
if len(preferred.UseWhen) == 0 || len(preferred.AvoidWhen) == 0 {
|
||||
failures = append(failures, fmt.Sprintf("intent %s preferred tool %q needs non-empty use_when and avoid_when", route.ID, route.PreferredTool))
|
||||
}
|
||||
|
||||
seenFallbacks := map[string]bool{}
|
||||
for _, fallback := range route.AllowedFallbacks {
|
||||
if fallback.Tool == "" || seenFallbacks[fallback.Tool] {
|
||||
failures = append(failures, fmt.Sprintf("intent %s has empty or duplicate allowed fallback %q", route.ID, fallback.Tool))
|
||||
continue
|
||||
}
|
||||
seenFallbacks[fallback.Tool] = true
|
||||
if !reasonCodePattern.MatchString(fallback.ReasonCode) {
|
||||
failures = append(failures, fmt.Sprintf("intent %s fallback %q has invalid reason_code %q", route.ID, fallback.Tool, fallback.ReasonCode))
|
||||
}
|
||||
fact, exists := tools[fallback.Tool]
|
||||
if !exists {
|
||||
failures = append(failures, fmt.Sprintf("intent %s fallback tool %q is absent from BoundCommandRegistry", route.ID, fallback.Tool))
|
||||
continue
|
||||
}
|
||||
if !fact.HasMeta || fact.Confirmation == "" {
|
||||
failures = append(failures, fmt.Sprintf("intent %s fallback tool %q is absent from ResolveMeta delivery", route.ID, fallback.Tool))
|
||||
} else if fact.Confirmation != preferred.Confirmation {
|
||||
failures = append(failures, fmt.Sprintf("intent %s fallback %q confirmation %q differs from preferred %q confirmation %q", route.ID, fallback.Tool, fact.Confirmation, route.PreferredTool, preferred.Confirmation))
|
||||
}
|
||||
}
|
||||
|
||||
seenForbidden := map[string]bool{}
|
||||
for _, canonical := range route.ForbiddenDefaultTools {
|
||||
if canonical == "" || seenForbidden[canonical] {
|
||||
failures = append(failures, fmt.Sprintf("intent %s has empty or duplicate forbidden default %q", route.ID, canonical))
|
||||
continue
|
||||
}
|
||||
seenForbidden[canonical] = true
|
||||
entry, exists := tools[canonical]
|
||||
if !exists {
|
||||
failures = append(failures, fmt.Sprintf("intent %s forbidden default tool %q is absent from BoundCommandRegistry", route.ID, canonical))
|
||||
continue
|
||||
}
|
||||
if !entry.HasMeta || entry.Confirmation == "" {
|
||||
failures = append(failures, fmt.Sprintf("intent %s forbidden default tool %q is absent from ResolveMeta delivery", route.ID, canonical))
|
||||
}
|
||||
if len(entry.UseWhen) == 0 || len(entry.AvoidWhen) == 0 {
|
||||
failures = append(failures, fmt.Sprintf("intent %s forbidden default tool %q needs non-empty use_when and avoid_when", route.ID, canonical))
|
||||
}
|
||||
}
|
||||
|
||||
seenReferences := map[string]bool{}
|
||||
for _, reference := range route.References {
|
||||
if !safeRepositoryPath(reference) || filepath.Ext(reference) != ".md" {
|
||||
failures = append(failures, fmt.Sprintf("intent %s has invalid reference %q", route.ID, reference))
|
||||
continue
|
||||
}
|
||||
if seenReferences[reference] {
|
||||
failures = append(failures, fmt.Sprintf("intent %s repeats reference %q", route.ID, reference))
|
||||
continue
|
||||
}
|
||||
seenReferences[reference] = true
|
||||
if _, err := os.Stat(filepath.Join(rootPath, filepath.FromSlash(reference))); err != nil {
|
||||
failures = append(failures, fmt.Sprintf("intent %s reference %q does not exist", route.ID, reference))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
markerFailures, markers := scanMarkers(rootPath, manifest.MarkerRoots, intentByID, tools)
|
||||
failures = append(failures, markerFailures...)
|
||||
for _, route := range manifest.Intents {
|
||||
for _, reference := range route.References {
|
||||
key := route.ID + "\x00" + filepath.ToSlash(reference)
|
||||
switch markers[key] {
|
||||
case 0:
|
||||
failures = append(failures, fmt.Sprintf("intent %s reference %s is missing its dws-intent marker", route.ID, reference))
|
||||
case 1:
|
||||
default:
|
||||
failures = append(failures, fmt.Sprintf("intent %s reference %s has %d dws-intent markers, want 1", route.ID, reference, markers[key]))
|
||||
}
|
||||
}
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
func validateRetiredScripts(rootPath string, paths []string) []string {
|
||||
var failures []string
|
||||
if len(paths) == 0 {
|
||||
return []string{"manifest retired_scripts must not be empty"}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, path := range paths {
|
||||
if !safeRepositoryPath(path) || filepath.Ext(path) != ".py" {
|
||||
failures = append(failures, fmt.Sprintf("invalid retired script path %q", path))
|
||||
continue
|
||||
}
|
||||
if seen[path] {
|
||||
failures = append(failures, fmt.Sprintf("duplicate retired script path %q", path))
|
||||
continue
|
||||
}
|
||||
seen[path] = true
|
||||
if _, err := os.Stat(filepath.Join(rootPath, filepath.FromSlash(path))); err == nil {
|
||||
failures = append(failures, fmt.Sprintf("retired script %s was republished", path))
|
||||
} else if !os.IsNotExist(err) {
|
||||
failures = append(failures, fmt.Sprintf("inspect retired script %s: %v", path, err))
|
||||
}
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
func validateTypedContractReference(rootPath, relative string) []string {
|
||||
if !safeRepositoryPath(relative) || filepath.Ext(relative) != ".md" {
|
||||
return []string{fmt.Sprintf("invalid contract_reference %q", relative)}
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(rootPath, filepath.FromSlash(relative)))
|
||||
if err != nil {
|
||||
return []string{fmt.Sprintf("read typed contract reference %s: %v", relative, err)}
|
||||
}
|
||||
data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n"))
|
||||
blocks := []struct {
|
||||
name string
|
||||
expected string
|
||||
}{
|
||||
{name: "MESSAGE_RESULT", expected: renderMessageResultContract()},
|
||||
{name: "IDENTITY_CAPABILITY", expected: renderIdentityCapabilityContract()},
|
||||
{name: "CARD_WORKFLOW", expected: renderCardWorkflowContract()},
|
||||
{name: "CAPABILITY_BOUNDARY", expected: renderCapabilityBoundaryContract()},
|
||||
}
|
||||
var failures []string
|
||||
for _, block := range blocks {
|
||||
start := "<!-- DWS_" + block.name + "_CONTRACT_START -->"
|
||||
end := "<!-- DWS_" + block.name + "_CONTRACT_END -->"
|
||||
expected := start + "\n" + block.expected + "\n" + end
|
||||
if bytes.Count(data, []byte(start)) != 1 || bytes.Count(data, []byte(end)) != 1 {
|
||||
failures = append(failures, fmt.Sprintf("%s must contain exactly one %s contract marker pair", relative, block.name))
|
||||
continue
|
||||
}
|
||||
startAt := bytes.Index(data, []byte(start))
|
||||
endAt := bytes.Index(data[startAt:], []byte(end))
|
||||
if endAt < 0 {
|
||||
failures = append(failures, fmt.Sprintf("%s has malformed %s contract markers", relative, block.name))
|
||||
continue
|
||||
}
|
||||
actual := string(data[startAt : startAt+endAt+len(end)])
|
||||
if actual != expected {
|
||||
failures = append(failures, fmt.Sprintf("%s %s contract differs from Runtime typed descriptor", relative, block.name))
|
||||
}
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
func validateEventHandoffReference(rootPath, relative string) []string {
|
||||
if !safeRepositoryPath(relative) || filepath.Ext(relative) != ".md" {
|
||||
return []string{fmt.Sprintf("invalid handoff_reference %q", relative)}
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(rootPath, filepath.FromSlash(relative)))
|
||||
if err != nil {
|
||||
return []string{fmt.Sprintf("read event handoff reference %s: %v", relative, err)}
|
||||
}
|
||||
data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n"))
|
||||
const expected = `<!-- DWS_EVENT_CHAT_HANDOFF_START -->
|
||||
| event field | exact chat target |
|
||||
|---|---|
|
||||
| ` + "`conversation_id`" + ` | ` + "`dws chat +messages-send --as user --group <conversation_id>`" + ` |
|
||||
| ` + "`sender_open_dingtalk_id`" + ` | ` + "`dws chat +messages-send --as user --open-dingtalk-id <sender_open_dingtalk_id>`" + ` |
|
||||
<!-- DWS_EVENT_CHAT_HANDOFF_END -->`
|
||||
start := bytes.Index(data, []byte("<!-- DWS_EVENT_CHAT_HANDOFF_START -->"))
|
||||
endMarker := "<!-- DWS_EVENT_CHAT_HANDOFF_END -->"
|
||||
if start < 0 || bytes.Count(data, []byte("<!-- DWS_EVENT_CHAT_HANDOFF_START -->")) != 1 ||
|
||||
bytes.Count(data, []byte(endMarker)) != 1 {
|
||||
return []string{fmt.Sprintf("%s must contain exactly one event-to-chat handoff marker pair", relative)}
|
||||
}
|
||||
end := bytes.Index(data[start:], []byte(endMarker))
|
||||
if end < 0 || string(data[start:start+end+len(endMarker)]) != expected {
|
||||
return []string{fmt.Sprintf("%s event-to-chat handoff differs from exact stable-ID mapping", relative)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderMessageResultContract() string {
|
||||
contract := chatmsg.CurrentMessageResultContract()
|
||||
return fmt.Sprintf("- `version`: `%s`\n- `message_fields`: %s\n- `envelope_fields`: %s",
|
||||
contract.Version, markdownCodeList(contract.MessageFields), markdownCodeList(contract.EnvelopeFields))
|
||||
}
|
||||
|
||||
func renderIdentityCapabilityContract() string {
|
||||
var rows []string
|
||||
for _, capability := range chatshortcut.MessageIdentityCapabilities() {
|
||||
rows = append(rows, fmt.Sprintf("| `%s` | %s | %s | %s | %s | `%t` | `%t` |",
|
||||
capability.Identity, markdownBreakList(capability.Targets), markdownBreakList(capability.ContentTypes),
|
||||
markdownBreakList(capability.NaturalTargets), markdownBreakList(capability.MentionTargets),
|
||||
capability.IdempotencyKeys, capability.BatchLedger))
|
||||
}
|
||||
return "| identity | targets | content types | natural targets | mention targets | idempotency keys | batch ledger |\n" +
|
||||
"|---|---|---|---|---|---:|---:|\n" + strings.Join(rows, "\n")
|
||||
}
|
||||
|
||||
func renderCardWorkflowContract() string {
|
||||
contract := chatshortcut.CurrentCardWorkflowContract()
|
||||
statuses := make([]string, 0, len(contract.FlowStatuses))
|
||||
for _, status := range contract.FlowStatuses {
|
||||
statuses = append(statuses, fmt.Sprintf("%d=%s", status.Value, status.Name))
|
||||
}
|
||||
return fmt.Sprintf("- `version`: `%s`\n- `targets`: %s\n- `content_types`: %s\n- `flow_statuses`: %s\n- `callback_supported`: `%t`",
|
||||
contract.Version, markdownCodeList(contract.Targets), markdownCodeList(contract.ContentTypes),
|
||||
markdownCodeList(statuses), contract.CallbackSupported)
|
||||
}
|
||||
|
||||
func renderCapabilityBoundaryContract() string {
|
||||
rows := make([]string, 0)
|
||||
for _, boundary := range chatshortcut.CurrentIMCapabilityBoundaries() {
|
||||
rows = append(rows, fmt.Sprintf("| `%s` | `%t` | %s |", boundary.Capability, boundary.Supported, boundary.Alternative))
|
||||
}
|
||||
return "| capability | supported | current route / boundary |\n|---|---:|---|\n" + strings.Join(rows, "\n")
|
||||
}
|
||||
|
||||
func markdownCodeList(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return "—"
|
||||
}
|
||||
quoted := make([]string, len(values))
|
||||
for index, value := range values {
|
||||
quoted[index] = "`" + value + "`"
|
||||
}
|
||||
return strings.Join(quoted, ", ")
|
||||
}
|
||||
|
||||
func markdownBreakList(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return "—"
|
||||
}
|
||||
quoted := make([]string, len(values))
|
||||
for index, value := range values {
|
||||
quoted[index] = "`" + value + "`"
|
||||
}
|
||||
return strings.Join(quoted, "<br>")
|
||||
}
|
||||
|
||||
func scanMarkers(rootPath string, roots []string, intents map[string]intentRoute, tools map[string]toolFact) ([]string, map[string]int) {
|
||||
var failures []string
|
||||
markers := map[string]int{}
|
||||
for _, relativeRoot := range roots {
|
||||
if !safeRepositoryPath(relativeRoot) {
|
||||
failures = append(failures, fmt.Sprintf("invalid marker root %q", relativeRoot))
|
||||
continue
|
||||
}
|
||||
absoluteRoot := filepath.Join(rootPath, filepath.FromSlash(relativeRoot))
|
||||
err := filepath.WalkDir(absoluteRoot, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() || filepath.Ext(path) != ".md" {
|
||||
return nil
|
||||
}
|
||||
file, err := markerOpen(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
relative, _ := filepath.Rel(rootPath, path)
|
||||
relative = filepath.ToSlash(relative)
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
lineNumber := 0
|
||||
for scanner.Scan() {
|
||||
lineNumber++
|
||||
line := scanner.Text()
|
||||
for _, match := range intentMarker.FindAllStringSubmatch(line, -1) {
|
||||
id := match[1]
|
||||
route, ok := intents[id]
|
||||
if !ok {
|
||||
failures = append(failures, fmt.Sprintf("%s:%d uses unknown dws-intent %q", relative, lineNumber, id))
|
||||
continue
|
||||
}
|
||||
if !stringSliceContains(route.References, relative) {
|
||||
failures = append(failures, fmt.Sprintf("%s:%d uses undeclared dws-intent %q", relative, lineNumber, id))
|
||||
}
|
||||
markers[id+"\x00"+relative]++
|
||||
preferred, exists := tools[route.PreferredTool]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if !containsCLIPath(line, preferred.PrimaryPath) {
|
||||
failures = append(failures, fmt.Sprintf("%s:%d intent %s must contain preferred path `dws %s` on the marker line", relative, lineNumber, id, preferred.PrimaryPath))
|
||||
}
|
||||
for _, canonical := range route.ForbiddenDefaultTools {
|
||||
if fact, ok := tools[canonical]; ok && containsCLIPath(line, fact.PrimaryPath) {
|
||||
failures = append(failures, fmt.Sprintf("%s:%d intent %s uses forbidden default `dws %s`", relative, lineNumber, id, fact.PrimaryPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
})
|
||||
if err != nil {
|
||||
failures = append(failures, fmt.Sprintf("scan marker root %s: %v", relativeRoot, err))
|
||||
}
|
||||
}
|
||||
return failures, markers
|
||||
}
|
||||
|
||||
func safeRepositoryPath(path string) bool {
|
||||
path = filepath.ToSlash(strings.TrimSpace(path))
|
||||
return path != "" && path != "." && !filepath.IsAbs(path) && path != ".." && !strings.HasPrefix(path, "../") && !strings.Contains(path, "/../")
|
||||
}
|
||||
|
||||
func containsCLIPath(line, path string) bool {
|
||||
needle := "dws " + strings.TrimSpace(path)
|
||||
index := strings.Index(line, needle)
|
||||
if index < 0 {
|
||||
return false
|
||||
}
|
||||
end := index + len(needle)
|
||||
if end == len(line) {
|
||||
return true
|
||||
}
|
||||
next := line[end]
|
||||
return next == ' ' || next == '`' || next == '<' || next == '|' || next == ','
|
||||
}
|
||||
|
||||
func stringSliceContains(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if filepath.ToSlash(value) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRunRepositoryAndSetupFailures(t *testing.T) {
|
||||
root, err := filepath.Abs("../../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run(root, app.NewRootCommand(), &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("repository policy run = %d, stdout=%s stderr=%s", code, stdout.String(), stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "multi IM Skill chain check: ok") {
|
||||
t.Fatalf("success output = %q", stdout.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := run(t.TempDir(), &cobra.Command{Use: "dws"}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "read IM intent route manifest") {
|
||||
t.Fatalf("missing-manifest run = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := run(root, &cobra.Command{Use: "dws"}, &stdout, &stderr); code != 1 || !strings.Contains(stderr.String(), "absent from BoundCommandRegistry") {
|
||||
t.Fatalf("unbound-root run = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
|
||||
oldBuild, oldBind := buildEffective, bindEffective
|
||||
t.Cleanup(func() { buildEffective, bindEffective = oldBuild, oldBind })
|
||||
buildEffective = func(*cobra.Command) (cli.EffectiveCommandRegistry, error) {
|
||||
return cli.EffectiveCommandRegistry{}, os.ErrInvalid
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := run(root, app.NewRootCommand(), &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "build effective CommandRegistry") {
|
||||
t.Fatalf("registry-build-failure run = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
buildEffective = oldBuild
|
||||
|
||||
bindEffective = func(*cobra.Command, cli.EffectiveCommandRegistry) (cli.BoundCommandRegistry, error) {
|
||||
return cli.BoundCommandRegistry{}, os.ErrInvalid
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := run(root, app.NewRootCommand(), &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "bind effective CommandRegistry") {
|
||||
t.Fatalf("binding-failure run = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
bindEffective = oldBind
|
||||
|
||||
failureRoot := t.TempDir()
|
||||
writeTestFile(t, failureRoot, manifestRelativePath, `{"version":1}`)
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := run(failureRoot, app.NewRootCommand(), &stdout, &stderr); code != 1 || !strings.Contains(stderr.String(), "multi IM Skill chain check failed") {
|
||||
t.Fatalf("validation-failure run = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMainUsesInjectableProcessBoundaries(t *testing.T) {
|
||||
oldGetwd, oldExit := mainGetwd, mainExit
|
||||
t.Cleanup(func() { mainGetwd, mainExit = oldGetwd, oldExit })
|
||||
var exitCode int
|
||||
mainExit = func(code int) { exitCode = code }
|
||||
mainGetwd = func() (string, error) { return "", os.ErrPermission }
|
||||
main()
|
||||
if exitCode != 2 {
|
||||
t.Fatalf("getwd failure exit = %d", exitCode)
|
||||
}
|
||||
root, err := filepath.Abs("../../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainGetwd = func() (string, error) { return root, nil }
|
||||
main()
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("successful main exit = %d", exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateManifestAcceptsExactReviewedRoute(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, root, "skills/multi/dingtalk-chat/SKILL.md", "| 发消息 | `dws chat +send` | <!-- dws-intent: chat.send -->\n")
|
||||
writeTypedContractReference(t, root, "skills/multi/dingtalk-chat/references/contracts.md")
|
||||
writeEventHandoffReference(t, root, "skills/multi/dingtalk-event/references/event-im-output.md")
|
||||
manifest := routeManifest{
|
||||
Version: 3,
|
||||
MarkerRoots: []string{"skills/multi/dingtalk-chat"},
|
||||
RetiredScripts: []string{"skills/multi/dingtalk-chat/scripts/retired.py"},
|
||||
ContractReference: "skills/multi/dingtalk-chat/references/contracts.md",
|
||||
HandoffReference: "skills/multi/dingtalk-event/references/event-im-output.md",
|
||||
Intents: []intentRoute{{
|
||||
ID: "chat.send",
|
||||
PreferredTool: "chat.shortcut_send",
|
||||
ForbiddenDefaultTools: []string{"chat.atomic_send"},
|
||||
References: []string{"skills/multi/dingtalk-chat/SKILL.md"},
|
||||
}},
|
||||
}
|
||||
tools := map[string]toolFact{
|
||||
"chat.shortcut_send": {Canonical: "chat.shortcut_send", PrimaryPath: "chat +send", Confirmation: "user_required", UseWhen: []string{"普通发送"}, AvoidWhen: []string{"底层字段另选"}, HasMeta: true},
|
||||
"chat.atomic_send": {Canonical: "chat.atomic_send", PrimaryPath: "chat message send", Confirmation: "not_required", UseWhen: []string{"底层字段"}, AvoidWhen: []string{"普通发送使用 +send"}, HasMeta: true},
|
||||
}
|
||||
if failures := validateManifest(root, manifest, tools); len(failures) != 0 {
|
||||
t.Fatalf("failures = %v", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateManifestRejectsWrongMarkerAndSafetyDowngrade(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, root, "skills/multi/dingtalk-chat/SKILL.md", "| 发消息 | `dws chat message send` | <!-- dws-intent: chat.send -->\n")
|
||||
writeTypedContractReference(t, root, "skills/multi/dingtalk-chat/references/contracts.md")
|
||||
writeEventHandoffReference(t, root, "skills/multi/dingtalk-event/references/event-im-output.md")
|
||||
manifest := routeManifest{
|
||||
Version: 3,
|
||||
MarkerRoots: []string{"skills/multi/dingtalk-chat"},
|
||||
RetiredScripts: []string{"skills/multi/dingtalk-chat/scripts/retired.py"},
|
||||
ContractReference: "skills/multi/dingtalk-chat/references/contracts.md",
|
||||
HandoffReference: "skills/multi/dingtalk-event/references/event-im-output.md",
|
||||
Intents: []intentRoute{{
|
||||
ID: "chat.send",
|
||||
PreferredTool: "chat.shortcut_send",
|
||||
AllowedFallbacks: []routeFallback{{Tool: "chat.atomic_send", ReasonCode: "raw_field"}},
|
||||
ForbiddenDefaultTools: []string{"chat.atomic_send"},
|
||||
References: []string{"skills/multi/dingtalk-chat/SKILL.md"},
|
||||
}},
|
||||
}
|
||||
tools := map[string]toolFact{
|
||||
"chat.shortcut_send": {Canonical: "chat.shortcut_send", PrimaryPath: "chat +send", Confirmation: "user_required", UseWhen: []string{"普通发送"}, AvoidWhen: []string{"底层字段另选"}, HasMeta: true},
|
||||
"chat.atomic_send": {Canonical: "chat.atomic_send", PrimaryPath: "chat message send", Confirmation: "not_required", UseWhen: []string{"普通发送"}, HasMeta: true},
|
||||
}
|
||||
failures := strings.Join(validateManifest(root, manifest, tools), "\n")
|
||||
for _, want := range []string{"confirmation", "needs non-empty use_when and avoid_when", "must contain preferred path", "uses forbidden default"} {
|
||||
if !strings.Contains(failures, want) {
|
||||
t.Fatalf("failures = %s, want %q", failures, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLoadManifestRejectsUnknownFields(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "manifest.json")
|
||||
if err := os.WriteFile(path, []byte(`{"version":1,"unknown":true}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadManifest(path); err == nil || !strings.Contains(err.Error(), "unknown field") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateRetiredScriptsRejectsRepublishedAndDuplicatePaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, root, "skills/multi/dingtalk-chat/scripts/export.py", "republished\n")
|
||||
failures := strings.Join(validateRetiredScripts(root, []string{
|
||||
"skills/multi/dingtalk-chat/scripts/export.py",
|
||||
"skills/multi/dingtalk-chat/scripts/export.py",
|
||||
"../unsafe.py",
|
||||
}), "\n")
|
||||
for _, want := range []string{"was republished", "duplicate retired", "invalid retired"} {
|
||||
if !strings.Contains(failures, want) {
|
||||
t.Fatalf("failures = %s, want %q", failures, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateTypedContractReferenceRejectsDrift(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTypedContractReference(t, root, "contracts.md")
|
||||
path := filepath.Join(root, "contracts.md")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data = []byte(strings.Replace(string(data), "`im.message-list.v1`", "`drifted`", 1))
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failures := strings.Join(validateTypedContractReference(root, "contracts.md"), "\n")
|
||||
if !strings.Contains(failures, "MESSAGE_RESULT contract differs") {
|
||||
t.Fatalf("failures = %s", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateEventHandoffReferenceRejectsNaturalTargetDrift(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeEventHandoffReference(t, root, "handoff.md")
|
||||
path := filepath.Join(root, "handoff.md")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data = []byte(strings.Replace(string(data), "--group <conversation_id>", "--chat-query <display_name>", 1))
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failures := strings.Join(validateEventHandoffReference(root, "handoff.md"), "\n")
|
||||
if !strings.Contains(failures, "exact stable-ID mapping") {
|
||||
t.Fatalf("failures = %s", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateReferencesAcceptCRLF(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
write func(*testing.T, string, string)
|
||||
validate func(string, string) []string
|
||||
}{
|
||||
{path: "contracts.md", write: writeTypedContractReference, validate: validateTypedContractReference},
|
||||
{path: "handoff.md", write: writeEventHandoffReference, validate: validateEventHandoffReference},
|
||||
} {
|
||||
tc.write(t, root, tc.path)
|
||||
path := filepath.Join(root, tc.path)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data = bytes.ReplaceAll(data, []byte("\n"), []byte("\r\n"))
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failures := tc.validate(root, tc.path); len(failures) != 0 {
|
||||
t.Errorf("%s CRLF failures = %v", tc.path, failures)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageManifestFailureMatrix(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTypedContractReference(t, root, "contracts.md")
|
||||
writeEventHandoffReference(t, root, "handoff.md")
|
||||
writeTestFile(t, root, "refs/declared.md", "<!-- dws-intent: declared --> missing preferred; dws chat forbidden\n<!-- dws-intent: declared --> duplicate\n")
|
||||
writeTestFile(t, root, "refs/markers.md", "<!-- dws-intent: unknown -->\n<!-- dws-intent: declared -->\n<!-- dws-intent: preferred-absent -->\n")
|
||||
tools := map[string]toolFact{
|
||||
"preferred.empty": {PrimaryPath: "chat preferred", HasMeta: false},
|
||||
"fallback.empty": {PrimaryPath: "chat fallback", HasMeta: false},
|
||||
"fallback.different": {PrimaryPath: "chat fallback-different", HasMeta: true, Confirmation: "not_required"},
|
||||
"forbidden.no-route": {PrimaryPath: "chat forbidden", HasMeta: true, Confirmation: "not_required", AvoidWhen: []string{"keep using lower tool"}},
|
||||
"forbidden.no-entry": {PrimaryPath: "chat forbidden-missing"},
|
||||
}
|
||||
manifest := routeManifest{
|
||||
Version: 1,
|
||||
MarkerRoots: []string{"refs", "../unsafe", "missing-root"},
|
||||
RetiredScripts: nil,
|
||||
ContractReference: "contracts.md",
|
||||
HandoffReference: "handoff.md",
|
||||
Intents: []intentRoute{
|
||||
{ID: "INVALID ID"},
|
||||
{ID: "preferred-absent", PreferredTool: "absent"},
|
||||
{ID: "preferred-no-selection", PreferredTool: "forbidden.no-entry"},
|
||||
{
|
||||
ID: "declared", PreferredTool: "preferred.empty",
|
||||
AllowedFallbacks: []routeFallback{
|
||||
{},
|
||||
{Tool: "fallback.empty", ReasonCode: "BAD-CODE"},
|
||||
{Tool: "fallback.empty", ReasonCode: "duplicate"},
|
||||
{Tool: "fallback.absent", ReasonCode: "missing"},
|
||||
{Tool: "fallback.different", ReasonCode: "raw_field"},
|
||||
},
|
||||
ForbiddenDefaultTools: []string{"", "forbidden.absent", "forbidden.no-entry", "forbidden.no-route", "forbidden.no-route"},
|
||||
References: []string{"../unsafe.md", "refs/wrong.txt", "refs/missing.md", "refs/declared.md", "refs/declared.md"},
|
||||
},
|
||||
{ID: "declared"},
|
||||
},
|
||||
}
|
||||
failures := strings.Join(validateManifest(root, manifest, tools), "\n")
|
||||
for _, want := range []string{
|
||||
"manifest version", "retired_scripts", "intent id",
|
||||
"preferred tool", "ResolveMeta", "needs non-empty", "allowed fallback", "reason_code", "fallback tool",
|
||||
"confirmation", "forbidden default", "invalid reference", "does not exist", "repeats reference",
|
||||
"unknown dws-intent", "undeclared dws-intent", "missing its dws-intent marker", "has 2 dws-intent markers",
|
||||
"invalid marker root", "scan marker root", "duplicate intent id",
|
||||
} {
|
||||
if !strings.Contains(failures, want) {
|
||||
t.Errorf("failure matrix missing %q:\n%s", want, failures)
|
||||
}
|
||||
}
|
||||
if failures := validateManifest(root, routeManifest{}, nil); len(failures) == 0 {
|
||||
t.Fatal("empty manifest unexpectedly passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReferenceFailureBranches(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if got := validateTypedContractReference(root, "../unsafe.md"); len(got) == 0 || !strings.Contains(got[0], "invalid contract_reference") {
|
||||
t.Fatalf("unsafe contract result = %#v", got)
|
||||
}
|
||||
if got := validateTypedContractReference(root, "missing.md"); len(got) == 0 || !strings.Contains(got[0], "read typed contract") {
|
||||
t.Fatalf("missing contract result = %#v", got)
|
||||
}
|
||||
writeTestFile(t, root, "malformed.md", "<!-- DWS_MESSAGE_RESULT_CONTRACT_END -->\n<!-- DWS_MESSAGE_RESULT_CONTRACT_START -->")
|
||||
if got := strings.Join(validateTypedContractReference(root, "malformed.md"), "\n"); !strings.Contains(got, "malformed MESSAGE_RESULT") || !strings.Contains(got, "marker pair") {
|
||||
t.Fatalf("malformed contract result = %s", got)
|
||||
}
|
||||
if got := validateEventHandoffReference(root, "../unsafe.md"); len(got) == 0 || !strings.Contains(got[0], "invalid handoff_reference") {
|
||||
t.Fatalf("unsafe handoff result = %#v", got)
|
||||
}
|
||||
if got := validateEventHandoffReference(root, "missing-handoff.md"); len(got) == 0 || !strings.Contains(got[0], "read event handoff") {
|
||||
t.Fatalf("missing handoff result = %#v", got)
|
||||
}
|
||||
writeTestFile(t, root, "handoff-markers.md", "no markers")
|
||||
if got := strings.Join(validateEventHandoffReference(root, "handoff-markers.md"), "\n"); !strings.Contains(got, "marker pair") {
|
||||
t.Fatalf("handoff marker result = %s", got)
|
||||
}
|
||||
|
||||
if markdownCodeList(nil) != "—" || markdownBreakList(nil) != "—" {
|
||||
t.Fatal("empty markdown list rendering drifted")
|
||||
}
|
||||
tooLong := strings.Repeat("x", 5000) + ".py"
|
||||
if got := strings.Join(validateRetiredScripts(root, []string{tooLong}), "\n"); !strings.Contains(got, "inspect retired script") {
|
||||
t.Fatalf("long retired script result = %s", got)
|
||||
}
|
||||
for _, path := range []string{"", ".", "..", "../x", "a/../b"} {
|
||||
if safeRepositoryPath(path) {
|
||||
t.Errorf("unsafe repository path accepted: %q", path)
|
||||
}
|
||||
}
|
||||
if containsCLIPath("run dws chat send-more", "chat send") || !containsCLIPath("run dws chat send`", "chat send") {
|
||||
t.Fatal("CLI path boundary check drifted")
|
||||
}
|
||||
if stringSliceContains([]string{"a/b"}, "missing") {
|
||||
t.Fatal("string slice containment false positive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMarkerOpenFailure(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, root, "refs/marker.md", "<!-- dws-intent: declared --> dws chat preferred\n")
|
||||
oldOpen := markerOpen
|
||||
markerOpen = func(string) (*os.File, error) { return nil, os.ErrPermission }
|
||||
t.Cleanup(func() { markerOpen = oldOpen })
|
||||
failures, _ := scanMarkers(root, []string{"refs"}, map[string]intentRoute{
|
||||
"declared": {ID: "declared", PreferredTool: "preferred", References: []string{"refs/marker.md"}},
|
||||
}, map[string]toolFact{"preferred": {PrimaryPath: "chat preferred"}})
|
||||
if len(failures) == 0 || !strings.Contains(failures[0], "scan marker root") {
|
||||
t.Fatalf("marker open failures = %#v", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTypedContractReference(t *testing.T, root, relative string) {
|
||||
t.Helper()
|
||||
var content strings.Builder
|
||||
for _, block := range []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "MESSAGE_RESULT", body: renderMessageResultContract()},
|
||||
{name: "IDENTITY_CAPABILITY", body: renderIdentityCapabilityContract()},
|
||||
{name: "CARD_WORKFLOW", body: renderCardWorkflowContract()},
|
||||
{name: "CAPABILITY_BOUNDARY", body: renderCapabilityBoundaryContract()},
|
||||
} {
|
||||
content.WriteString("<!-- DWS_" + block.name + "_CONTRACT_START -->\n")
|
||||
content.WriteString(block.body)
|
||||
content.WriteString("\n<!-- DWS_" + block.name + "_CONTRACT_END -->\n")
|
||||
}
|
||||
writeTestFile(t, root, relative, content.String())
|
||||
}
|
||||
|
||||
func writeEventHandoffReference(t *testing.T, root, relative string) {
|
||||
t.Helper()
|
||||
writeTestFile(t, root, relative, `<!-- DWS_EVENT_CHAT_HANDOFF_START -->
|
||||
| event field | exact chat target |
|
||||
|---|---|
|
||||
| `+"`conversation_id`"+` | `+"`dws chat +messages-send --as user --group <conversation_id>`"+` |
|
||||
| `+"`sender_open_dingtalk_id`"+` | `+"`dws chat +messages-send --as user --open-dingtalk-id <sender_open_dingtalk_id>`"+` |
|
||||
<!-- DWS_EVENT_CHAT_HANDOFF_END -->
|
||||
`)
|
||||
}
|
||||
|
||||
func writeTestFile(t *testing.T, root, relative, content string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(root, filepath.FromSlash(relative))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"version": 3,
|
||||
"marker_roots": [
|
||||
"skills/multi/dingtalk-chat",
|
||||
"skills/multi/dingtalk-event"
|
||||
],
|
||||
"retired_scripts": [
|
||||
"skills/multi/dingtalk-chat/scripts/_chat_message_export_common.py",
|
||||
"skills/multi/dingtalk-chat/scripts/chat_export_messages.py",
|
||||
"skills/multi/dingtalk-chat/scripts/chat_history_with_user.py",
|
||||
"skills/multi/dingtalk-chat/scripts/bot_broadcast.py",
|
||||
"skills/mono/scripts/_chat_message_export_common.py",
|
||||
"skills/mono/scripts/chat_export_messages.py",
|
||||
"skills/mono/scripts/chat_history_with_user.py",
|
||||
"skills/mono/scripts/bot_broadcast.py"
|
||||
],
|
||||
"contract_reference": "skills/multi/dingtalk-chat/references/contracts.md",
|
||||
"handoff_reference": "skills/multi/dingtalk-event/references/event-im-output.md",
|
||||
"intents": [
|
||||
{
|
||||
"id": "chat.send.dm",
|
||||
"preferred_tool": "chat.shortcut_dm",
|
||||
"forbidden_default_tools": [
|
||||
"chat.send_personal_message"
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chat.send.group",
|
||||
"preferred_tool": "chat.shortcut_send_to_group",
|
||||
"forbidden_default_tools": [
|
||||
"chat.send_personal_message"
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chat.send.advanced",
|
||||
"preferred_tool": "chat.shortcut_messages_send",
|
||||
"forbidden_default_tools": [
|
||||
"chat.send_personal_message",
|
||||
"chat.send_robot_message",
|
||||
"chat.send_message_by_custom_robot",
|
||||
"chat.shortcut_messages_send_by_webhook"
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-bot.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chat.read.conversation",
|
||||
"preferred_tool": "chat.shortcut_chat_messages",
|
||||
"allowed_fallbacks": [
|
||||
{
|
||||
"tool": "chat.list_conversation_message_v2",
|
||||
"reason_code": "raw_page_control"
|
||||
}
|
||||
],
|
||||
"forbidden_default_tools": [
|
||||
"chat.list_conversation_message_v2"
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-conversation.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chat.search.cross-conversation",
|
||||
"preferred_tool": "chat.shortcut_search_msg",
|
||||
"allowed_fallbacks": [
|
||||
{
|
||||
"tool": "chat.search_messages",
|
||||
"reason_code": "raw_filter_control"
|
||||
}
|
||||
],
|
||||
"forbidden_default_tools": [
|
||||
"chat.search_messages",
|
||||
"chat.search_messages_by_keyword"
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chat.create.group",
|
||||
"preferred_tool": "chat.shortcut_chat_create",
|
||||
"forbidden_default_tools": [
|
||||
"chat.create_group_conversation"
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-group.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chat.reply.quote",
|
||||
"preferred_tool": "chat.shortcut_messages_reply",
|
||||
"forbidden_default_tools": [
|
||||
"chat.reply_personal_message"
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chat.conversation.list-top",
|
||||
"preferred_tool": "chat.shortcut_conversation_list_top",
|
||||
"allowed_fallbacks": [
|
||||
{
|
||||
"tool": "chat.list_top_conversations",
|
||||
"reason_code": "raw_response_required"
|
||||
}
|
||||
],
|
||||
"forbidden_default_tools": [
|
||||
"chat.list_top_conversations"
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-conversation.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "event.listen.im",
|
||||
"preferred_tool": "event.listen_im",
|
||||
"allowed_fallbacks": [
|
||||
{
|
||||
"tool": "event.consume",
|
||||
"reason_code": "advanced_event_control"
|
||||
}
|
||||
],
|
||||
"forbidden_default_tools": [
|
||||
"event.consume"
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-event/SKILL.md",
|
||||
"skills/multi/dingtalk-event/references/event-im.md"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -492,7 +492,7 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri
|
||||
}
|
||||
if oldTool.Constraints != newTool.Constraints &&
|
||||
!compatibleHiddenSiblingConstraintExpansion(oldTool, newTool) &&
|
||||
!compatibleConstraintMemberExpansion(oldTool.Constraints, newTool.Constraints) {
|
||||
!compatibleAdditiveConstraintEvolution(oldTool, newTool) {
|
||||
failures = append(failures, fmt.Sprintf("schema tool %q changed constraints", toolPath))
|
||||
}
|
||||
if !compatiblePositionals(oldTool.Positionals, newTool.Positionals) {
|
||||
@@ -514,6 +514,86 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri
|
||||
return failures
|
||||
}
|
||||
|
||||
// compatibleAdditiveConstraintEvolution accepts constraint evolution that
|
||||
// cannot invalidate an invocation expressible by the historical public
|
||||
// parameter contract. Existing groups may only gain members; additions to a
|
||||
// mutually-exclusive or require-together group must not be historical public
|
||||
// parameters, because that would reject an invocation expressible by the old
|
||||
// contract. Adding a member to require-one-of only loosens the group. A newly
|
||||
// added mutually-exclusive group is safe when it contains at most one
|
||||
// historical public parameter: aliases and newly added parameters could not
|
||||
// have appeared together in an old invocation. A new require-together group is
|
||||
// safe only when it contains no historical public parameter. A new
|
||||
// require-one-of group always adds a requirement and is therefore incompatible.
|
||||
func compatibleAdditiveConstraintEvolution(oldTool, newTool toolSchema) bool {
|
||||
oldGroups, okOld := parseConstraintGroups(oldTool.Constraints)
|
||||
newGroups, okNew := parseConstraintGroups(newTool.Constraints)
|
||||
if !okOld || !okNew {
|
||||
return false
|
||||
}
|
||||
for _, key := range []string{"mutually_exclusive", "require_one_of", "require_together"} {
|
||||
used := make([]bool, len(newGroups[key]))
|
||||
for _, oldGroup := range oldGroups[key] {
|
||||
oldSet := stringSet(oldGroup)
|
||||
if len(oldSet) == 0 {
|
||||
return false
|
||||
}
|
||||
matched := false
|
||||
for index, newGroup := range newGroups[key] {
|
||||
newSet := stringSet(newGroup)
|
||||
if used[index] || !stringSetContainsAll(newSet, oldSet) {
|
||||
continue
|
||||
}
|
||||
if key == "mutually_exclusive" || key == "require_together" {
|
||||
safe := true
|
||||
for member := range newSet {
|
||||
if oldSet[member] {
|
||||
continue
|
||||
}
|
||||
if _, historical := oldTool.Parameters[member]; historical {
|
||||
safe = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !safe {
|
||||
continue
|
||||
}
|
||||
}
|
||||
used[index] = true
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
if !matched {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for index, newGroup := range newGroups[key] {
|
||||
if used[index] {
|
||||
continue
|
||||
}
|
||||
historicalMembers := 0
|
||||
for member := range stringSet(newGroup) {
|
||||
if _, existed := oldTool.Parameters[member]; existed {
|
||||
historicalMembers++
|
||||
}
|
||||
}
|
||||
switch key {
|
||||
case "mutually_exclusive":
|
||||
if historicalMembers > 1 {
|
||||
return false
|
||||
}
|
||||
case "require_together":
|
||||
if historicalMembers > 0 {
|
||||
return false
|
||||
}
|
||||
default: // require_one_of
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// compatibleHiddenSiblingConstraintExpansion allows declare≡execute repairs:
|
||||
// Schema may start projecting full constraint groups that include unpublished
|
||||
// (hidden) execute-side siblings when the previous contract collapsed the sole
|
||||
@@ -574,23 +654,6 @@ func compatibleHiddenSiblingConstraintExpansion(oldTool, newTool toolSchema) boo
|
||||
return true
|
||||
}
|
||||
|
||||
// compatibleConstraintMemberExpansion allows declare≡execute / alias-surface
|
||||
// repairs that only add members to existing constraint groups. Removing a
|
||||
// member, dropping a group, or adding a new group remains incompatible.
|
||||
func compatibleConstraintMemberExpansion(oldConstraints, newConstraints string) bool {
|
||||
oldGroups, okOld := parseConstraintGroups(oldConstraints)
|
||||
newGroups, okNew := parseConstraintGroups(newConstraints)
|
||||
if !okOld || !okNew {
|
||||
return false
|
||||
}
|
||||
for _, key := range []string{"mutually_exclusive", "require_one_of", "require_together"} {
|
||||
if !constraintGroupsAreMemberExpansions(oldGroups[key], newGroups[key]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseConstraintGroups(raw string) (map[string][][]string, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
@@ -611,36 +674,6 @@ func parseConstraintGroups(raw string) (map[string][][]string, bool) {
|
||||
}, true
|
||||
}
|
||||
|
||||
func constraintGroupsAreMemberExpansions(oldGroups, newGroups [][]string) bool {
|
||||
if len(oldGroups) != len(newGroups) {
|
||||
return false
|
||||
}
|
||||
used := make([]bool, len(newGroups))
|
||||
for _, oldGroup := range oldGroups {
|
||||
oldSet := stringSet(oldGroup)
|
||||
if len(oldSet) == 0 {
|
||||
return false
|
||||
}
|
||||
matched := false
|
||||
for index, newGroup := range newGroups {
|
||||
if used[index] {
|
||||
continue
|
||||
}
|
||||
newSet := stringSet(newGroup)
|
||||
if !stringSetContainsAll(newSet, oldSet) {
|
||||
continue
|
||||
}
|
||||
used[index] = true
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
if !matched {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func stringSetContainsAll(superset, subset map[string]bool) bool {
|
||||
for value := range subset {
|
||||
if !superset[value] {
|
||||
|
||||
@@ -492,10 +492,10 @@ func TestCrossPlatformCoverageSchemaCompatMCPRetirementAndConstraintExpansion(t
|
||||
parameter.InterfaceType = ""
|
||||
})
|
||||
mutateTool(¤t, func(tool *toolSchema) {
|
||||
tool.Constraints = `{"require_one_of":[["title","format","legacy-format"]],"mutually_exclusive":[["title","legacy-title"]]}`
|
||||
tool.Constraints = `{"require_one_of":[["title","format","legacy-format"]],"mutually_exclusive":[["title","format","legacy-title"]]}`
|
||||
})
|
||||
// Baseline has require_one_of only; expanding with a new mutually_exclusive
|
||||
// group must remain incompatible.
|
||||
// Baseline has require_one_of only; a new mutually-exclusive group that
|
||||
// restricts two historical public parameters must remain incompatible.
|
||||
if failures := checkCompatibility(baseline, current); len(failures) == 0 {
|
||||
t.Fatal("adding a new constraint group must fail compatibility")
|
||||
}
|
||||
@@ -532,36 +532,9 @@ func TestCrossPlatformCoverageSchemaCompatMCPRetirementAndConstraintExpansion(t
|
||||
t.Fatalf("hidden-sibling constraint expansion should pass: %v", failures)
|
||||
}
|
||||
|
||||
if compatibleConstraintMemberExpansion("{", `{"require_one_of":[["a"]]}`) {
|
||||
t.Fatal("invalid old constraints must not expand")
|
||||
}
|
||||
if compatibleConstraintMemberExpansion(`{"require_one_of":[["a"]]}`, "{") {
|
||||
t.Fatal("invalid new constraints must not expand")
|
||||
}
|
||||
if groups, ok := parseConstraintGroups(""); !ok || len(groups["require_one_of"]) != 0 {
|
||||
t.Fatalf("empty constraints parse = %#v ok=%v", groups, ok)
|
||||
}
|
||||
if constraintGroupsAreMemberExpansions([][]string{{}}, [][]string{{"a"}}) {
|
||||
t.Fatal("empty old group must not expand")
|
||||
}
|
||||
if constraintGroupsAreMemberExpansions(
|
||||
[][]string{{"a"}, {"b"}},
|
||||
[][]string{{"a", "b"}},
|
||||
) {
|
||||
t.Fatal("group-count reduction must not expand")
|
||||
}
|
||||
if constraintGroupsAreMemberExpansions(
|
||||
[][]string{{"a"}, {"c"}},
|
||||
[][]string{{"a", "b"}, {"x", "y"}},
|
||||
) {
|
||||
t.Fatal("unmatched old group must not expand")
|
||||
}
|
||||
if !constraintGroupsAreMemberExpansions(
|
||||
[][]string{{"a"}, {"b"}},
|
||||
[][]string{{"a", "b", "extra"}, {"b", "other"}},
|
||||
) {
|
||||
t.Fatal("used-group skip should still allow alternate match")
|
||||
}
|
||||
if !stringSetContainsAll(map[string]bool{"a": true, "b": true}, map[string]bool{"a": true}) ||
|
||||
stringSetContainsAll(map[string]bool{"a": true}, map[string]bool{"a": true, "b": true}) {
|
||||
t.Fatal("stringSetContainsAll classification is incorrect")
|
||||
@@ -622,3 +595,87 @@ func TestCrossPlatformCoverageSchemaCompatMCPRetirementAndConstraintExpansion(t
|
||||
t.Fatalf("non-empty interface_type change should fail: %v", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCompatAdditiveConstraintEvolution(t *testing.T) {
|
||||
oldTool := toolSchema{
|
||||
Parameters: map[string]parameterSchema{
|
||||
"target": {},
|
||||
"limit": {},
|
||||
},
|
||||
Constraints: `{"require_one_of":[["target","legacy-target"]]}`,
|
||||
}
|
||||
compatible := oldTool
|
||||
compatible.Constraints = `{"require_one_of":[["target","legacy-target","new-target"]],"mutually_exclusive":[["target","new-target"],["new-a","new-b"]],"require_together":[["new-a","new-b"]]}`
|
||||
if !compatibleAdditiveConstraintEvolution(oldTool, compatible) {
|
||||
t.Fatal("member expansion and additive alias-only groups must remain compatible")
|
||||
}
|
||||
|
||||
twoHistorical := compatible
|
||||
twoHistorical.Constraints = `{"require_one_of":[["target","legacy-target","new-target"]],"mutually_exclusive":[["target","limit","new-target"]]}`
|
||||
if compatibleAdditiveConstraintEvolution(oldTool, twoHistorical) {
|
||||
t.Fatal("new mutex group restricting two historical parameters must fail")
|
||||
}
|
||||
newRequirement := compatible
|
||||
newRequirement.Constraints = `{"require_one_of":[["target","legacy-target","new-target"],["new-a","new-b"]]}`
|
||||
if compatibleAdditiveConstraintEvolution(oldTool, newRequirement) {
|
||||
t.Fatal("new require_one_of group must fail")
|
||||
}
|
||||
requireHistoricalTogether := compatible
|
||||
requireHistoricalTogether.Constraints = `{"require_one_of":[["target","legacy-target","new-target"]],"require_together":[["limit","new-limit"]]}`
|
||||
if compatibleAdditiveConstraintEvolution(oldTool, requireHistoricalTogether) {
|
||||
t.Fatal("new require_together group containing a historical parameter must fail")
|
||||
}
|
||||
removedOldGroup := compatible
|
||||
removedOldGroup.Constraints = `{"mutually_exclusive":[["new-a","new-b"]]}`
|
||||
if compatibleAdditiveConstraintEvolution(oldTool, removedOldGroup) {
|
||||
t.Fatal("removing a historical group must fail")
|
||||
}
|
||||
invalid := compatible
|
||||
invalid.Constraints = "{"
|
||||
if compatibleAdditiveConstraintEvolution(oldTool, invalid) {
|
||||
t.Fatal("invalid constraints must fail closed")
|
||||
}
|
||||
emptyHistoricalGroup := oldTool
|
||||
emptyHistoricalGroup.Constraints = `{"require_one_of":[[]]}`
|
||||
if compatibleAdditiveConstraintEvolution(emptyHistoricalGroup, compatible) {
|
||||
t.Fatal("empty historical constraint group must fail closed")
|
||||
}
|
||||
historicalMutex := oldTool
|
||||
historicalMutex.Constraints = `{"mutually_exclusive":[["target","legacy-target"]]}`
|
||||
historicalMutexExpanded := oldTool
|
||||
historicalMutexExpanded.Constraints = `{"mutually_exclusive":[["target","legacy-target","limit"]]}`
|
||||
if compatibleAdditiveConstraintEvolution(historicalMutex, historicalMutexExpanded) {
|
||||
t.Fatal("adding a historical parameter to an existing mutex group must fail")
|
||||
}
|
||||
historicalTogether := oldTool
|
||||
historicalTogether.Constraints = `{"require_together":[["target","legacy-target"]]}`
|
||||
historicalTogetherExpanded := oldTool
|
||||
historicalTogetherExpanded.Constraints = `{"require_together":[["target","legacy-target","limit"]]}`
|
||||
if compatibleAdditiveConstraintEvolution(historicalTogether, historicalTogetherExpanded) {
|
||||
t.Fatal("adding a historical parameter to an existing require-together group must fail")
|
||||
}
|
||||
historicalOneOf := oldTool
|
||||
historicalOneOf.Constraints = `{"require_one_of":[["target","legacy-target","limit"]]}`
|
||||
if !compatibleAdditiveConstraintEvolution(oldTool, historicalOneOf) {
|
||||
t.Fatal("adding a historical parameter to require-one-of only loosens the contract")
|
||||
}
|
||||
gateBaseline := baselineContract()
|
||||
mutateTool(&gateBaseline, func(tool *toolSchema) {
|
||||
tool.Parameters["folder"] = parameterSchema{Type: `"string"`}
|
||||
tool.Constraints = `{"mutually_exclusive":[["title","format"]]}`
|
||||
})
|
||||
gateCurrent := cloneContract(gateBaseline)
|
||||
mutateTool(&gateCurrent, func(tool *toolSchema) {
|
||||
tool.Constraints = `{"mutually_exclusive":[["title","format","folder"]]}`
|
||||
})
|
||||
if failures := checkCompatibility(gateBaseline, gateCurrent); len(failures) == 0 {
|
||||
t.Fatal("compatibility gate must reject an existing mutex group gaining a historical parameter")
|
||||
}
|
||||
multipleHistoricalGroups := oldTool
|
||||
multipleHistoricalGroups.Constraints = `{"require_one_of":[["target"],["limit"]]}`
|
||||
multipleExpandedGroups := oldTool
|
||||
multipleExpandedGroups.Constraints = `{"require_one_of":[["target","limit"],["limit","new-limit"]]}`
|
||||
if !compatibleAdditiveConstraintEvolution(multipleHistoricalGroups, multipleExpandedGroups) {
|
||||
t.Fatal("each historical group must match a distinct expanded group")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +306,23 @@ def reaction_message_count(value: Any, *, upper: bool) -> int:
|
||||
return count
|
||||
|
||||
|
||||
def primary_projection_for_message_metrics(value: Any) -> Any:
|
||||
"""Select one canonical projected collection for message-level metrics.
|
||||
|
||||
Some stable Shortcut payloads publish compatibility aliases such as
|
||||
``messages`` and ``items`` with the same rows. Walking the entire envelope
|
||||
would count reactions and thread IDs once per alias and report a false
|
||||
projection mismatch even though the lower and upper message sets agree.
|
||||
"""
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
for key in ("messages", "replies", "results", "items"):
|
||||
items = value.get(key)
|
||||
if isinstance(items, list):
|
||||
return items
|
||||
return value
|
||||
|
||||
|
||||
def thread_message_count(value: Any, *, upper: bool) -> int:
|
||||
keys = {"threadId"} if upper else {
|
||||
"openConvThreadId",
|
||||
@@ -393,9 +410,10 @@ def summarize_capture(capture: Capture) -> dict[str, Any]:
|
||||
else 0
|
||||
)
|
||||
exact_passthrough = bool(capture.raw_calls and upper == raw)
|
||||
upper_reactions = reaction_message_count(upper, upper=True)
|
||||
upper_message_projection = primary_projection_for_message_metrics(upper)
|
||||
upper_reactions = reaction_message_count(upper_message_projection, upper=True)
|
||||
lower_reactions = reaction_message_count(raw, upper=False)
|
||||
upper_threads = thread_message_count(upper, upper=True)
|
||||
upper_threads = thread_message_count(upper_message_projection, upper=True)
|
||||
lower_threads = thread_message_count(raw, upper=False)
|
||||
upper_pagination = pagination_meta(upper)
|
||||
lower_pagination = pagination_meta(pagination_raw)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic regression checks for the live Chat audit summarizer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from run_chat_shortcut_live_audit import (
|
||||
primary_projection_for_message_metrics,
|
||||
reaction_message_count,
|
||||
thread_message_count,
|
||||
)
|
||||
|
||||
|
||||
def check(name: str, condition: bool) -> None:
|
||||
if not condition:
|
||||
raise AssertionError(f"FAIL: {name}")
|
||||
print(f" ok: {name}")
|
||||
|
||||
|
||||
def test_compatibility_aliases_are_counted_once() -> None:
|
||||
rows = [{"threadId": "thread-1", "reactions": [{"type": "LIKE"}]}]
|
||||
payload = {"messages": rows, "items": rows, "count": 1}
|
||||
projection = primary_projection_for_message_metrics(payload)
|
||||
|
||||
check("messages is the canonical projection", projection is rows)
|
||||
check("reaction-bearing message counted once", reaction_message_count(projection, upper=True) == 1)
|
||||
check("thread-bearing message counted once", thread_message_count(projection, upper=True) == 1)
|
||||
|
||||
|
||||
def test_projection_fallback_order() -> None:
|
||||
replies = [{"threadId": "thread-2"}]
|
||||
items = [{"threadId": "thread-3"}]
|
||||
check(
|
||||
"replies precedes items",
|
||||
primary_projection_for_message_metrics({"replies": replies, "items": items}) is replies,
|
||||
)
|
||||
check("items remains a supported fallback", primary_projection_for_message_metrics({"items": items}) is items)
|
||||
|
||||
|
||||
def test_non_envelope_passthrough() -> None:
|
||||
rows = [{"threadId": "thread-4"}]
|
||||
check("bare list passes through", primary_projection_for_message_metrics(rows) is rows)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
tests = [
|
||||
test_compatibility_aliases_are_counted_once,
|
||||
test_projection_fallback_order,
|
||||
test_non_envelope_passthrough,
|
||||
]
|
||||
for test in tests:
|
||||
print(f"{test.__name__}:")
|
||||
test()
|
||||
print(f"\nAll {len(tests)} test groups passed.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -474,9 +474,22 @@ def run_live(binary: Path, timeout: int) -> list[dict[str, Any]]:
|
||||
)
|
||||
role_id = ""
|
||||
|
||||
# The platform only permits the @all and red-envelope sub-switches
|
||||
# while the conversation-wide notification-off switch is enabled.
|
||||
# Keep that prerequisite active across both cases, then restore it.
|
||||
conversation_mute_captures = [
|
||||
run_capture(
|
||||
binary,
|
||||
"+conversation-mute",
|
||||
["--conversation-id", group_id, "--yes"],
|
||||
timeout,
|
||||
)
|
||||
]
|
||||
# Red-envelope must be exercised first. Toggling the @all sub-switch
|
||||
# can make the service clear the prerequisite state for the next call.
|
||||
shortcut_case(
|
||||
"+conversation-mute",
|
||||
"update_notification_off",
|
||||
"+conversation-mute-red-envelope",
|
||||
"update_red_env_notification_off",
|
||||
[
|
||||
["--conversation-id", group_id],
|
||||
["--conversation-id", group_id, "--off"],
|
||||
@@ -490,13 +503,20 @@ def run_live(binary: Path, timeout: int) -> list[dict[str, Any]]:
|
||||
["--conversation-id", group_id, "--off"],
|
||||
],
|
||||
)
|
||||
shortcut_case(
|
||||
"+conversation-mute-red-envelope",
|
||||
"update_red_env_notification_off",
|
||||
[
|
||||
["--conversation-id", group_id],
|
||||
["--conversation-id", group_id, "--off"],
|
||||
],
|
||||
conversation_mute_captures.append(
|
||||
run_capture(
|
||||
binary,
|
||||
"+conversation-mute",
|
||||
["--conversation-id", group_id, "--off", "--yes"],
|
||||
timeout,
|
||||
)
|
||||
)
|
||||
results.append(
|
||||
summarize_action(
|
||||
"+conversation-mute",
|
||||
"update_notification_off",
|
||||
conversation_mute_captures,
|
||||
)
|
||||
)
|
||||
shortcut_case(
|
||||
"+conversation-set-top",
|
||||
|
||||
@@ -21,14 +21,14 @@ cli_version: ">=1.0.15"
|
||||
- 危险操作必须先向用户确认,用户同意后才加 `--yes` 执行
|
||||
- 单次批量操作不超过 30 条记录
|
||||
- 所有命令必须**严格遵循**对应产品参考文档里面规定的参数格式(如:如果有参数值,则参数和参数值之间至少用一个空格隔开)
|
||||
- **脚本优先**:[scripts/](./scripts/) 下的 `python scripts/<name>.py` 已封装翻页/轮询/批量逻辑,遇到对应场景(如 AI 表格批量导入导出、AI 应用创建轮询、文档创建后写内容、钉盘目录树等)**优先调用脚本**而非手写多步命令。脚本均支持 `--dry-run` 预览、`--format json` 输出,失败时回退到手动步骤
|
||||
- **实时个人消息事件例外**:用户要监听消息、订阅事件、自动回复消息或事件驱动 Agent 时,必须走 `dws event consume ... --flatten` 长连接,不要写脚本轮询消息历史
|
||||
- **脚本只用于明确覆盖的复合任务**:[scripts/](./scripts/) 下的脚本可封装 AI 表格批量导入导出、AI 应用创建轮询、文档创建后写内容、钉盘目录树等流程;当公开 `+` Shortcut 已提供目标唯一解析、分页/部分失败 ledger 和确认语义时,优先 Shortcut。Chat 历史导出与机器人广播已完全下沉 Runtime,不再发布兼容脚本
|
||||
- **实时个人消息事件例外**:用户要监听消息、订阅事件、自动回复消息或事件驱动 Agent 时,默认走 `dws event +listen-im ...`;只有明确需要多个原始 EventKey、Filter DSL、subscribe_id 或原始 envelope 时才用 `dws event consume ... --flatten`,不要写脚本轮询消息历史
|
||||
|
||||
## Shortcut 与原子命令的使用原则
|
||||
|
||||
`shortcut` 是对常用操作的高层封装,适合优先承担用户意图;产品参考文档和本 skill 负责判断意图、风险、跨产品流程和复杂参数,CLI 帮助负责声明当前版本真正可调用的命令。
|
||||
|
||||
- 先按产品参考、意图表和 recipe 路由。存在精确覆盖场景的专用脚本/recipe 时继续遵循“脚本优先”;否则用户意图可由可见 shortcut 满足时,优先使用 `dws <service> +<verb> ... --format json`,不要手写等价的多步原子命令。
|
||||
- 先按产品参考、意图表和 recipe 路由。用户意图可由可见 Shortcut 满足时,优先使用 `dws <service> +<verb> ... --format json`,不要手写等价的多步原子命令。只有脚本明确补足 Shortcut 未覆盖的复合交付物且其安全/完整性契约仍适用时才选择脚本。
|
||||
- 公开内建 shortcut 同时进入 Runtime Schema。用 `dws schema --cli-path "<service> +<verb>" --format json` 读取 Agent 选择、参数、跨参数约束、risk/confirmation 与接口语义;`dws shortcut list --service <service> --format json` 只作为轻量批量发现入口。
|
||||
- 真正组装参数前用叶子帮助 `dws <service> +<verb> --help` 核对当前 Cobra 接受的 flags。父级 `dws <service> --help` 只能发现子命令,不能替代叶子参数帮助。
|
||||
- shortcut catalog 中 `confirmation=user_required` 时,必须先获得用户确认,确认后才加 `--yes`;`not_required` 不额外确认。
|
||||
@@ -120,9 +120,9 @@ cli_version: ">=1.0.15"
|
||||
用户提到"在线电子表格/钉钉表格/axls/工作表/单元格读写/合并单元格/筛选视图/导出 xlsx" → `sheet`
|
||||
用户提到"待办/TODO/任务提醒/循环待办" → `todo`
|
||||
用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki`
|
||||
用户提到"监听有人@我/监听单聊或群消息/监听所有单聊或群消息/监听某人发送的消息/监听消息已读/监听消息撤回/监听消息贴表情或表情回应/监听群成员加入/监听群成员退出/监听群改名或群解散/订阅个人 IM 事件/实时接收钉钉事件/个人事件流/event consume user_im_message_*/监听并自动回复消息/驱动 Agent 处理消息" → `event`
|
||||
用户提到"监听有人@我/监听单聊或群消息/监听所有单聊或群消息/监听某人发送的消息/监听消息已读/监听消息撤回/监听消息贴表情或表情回应/订阅个人 IM 事件/实时接收钉钉事件/监听并自动回复消息/驱动 Agent 处理消息" → `event +listen-im`;群成员加入/退出、群改名/解散或明确原始 EventKey/Filter DSL → `event consume`
|
||||
|
||||
事件监听中,同一目标、同一过滤条件的多个兼容事件优先生成一个 `dws event consume <event_key> [event_key...] --flatten`;不同用户、不同群或不同过滤条件拆成多个 consume 进程。
|
||||
普通消息、reaction、已读、撤回监听优先由一个 `dws event +listen-im` 进程表达目标;不同用户、不同群或不同过滤条件拆成独立进程。只有高级事件控制才生成 `dws event consume <event_key> [event_key...] --flatten`。
|
||||
|
||||
关键区分: aitable(数据表格) vs todo(待办任务)
|
||||
关键区分: report(钉钉日志/日报周报) vs todo(待办任务)
|
||||
@@ -292,7 +292,7 @@ Schema 与 Help 冲突是**契约漂移**,不得静默猜测或把两边字段
|
||||
`source` 表示最终命令 identity 的来源,不表示运行时 backing;helper/local/MCP 实现机制读取 `interface_mode`、`availability` 和 provenance,不要假定 `dev.*` 必然是 `source=mcp:<server>`,也不要假定本地命令必然是 `source=cobra`。
|
||||
|
||||
## 错误处理
|
||||
1. 遇到错误,加 `--verbose` 重试一次
|
||||
1. 先读取 JSON 错误的 `retryable`、`retry_after_seconds`、`next_retry_at`、`hint` 和 `actions`;只有明确 `retryable=true` 时才按服务端节奏做一次有界重试。缺少重试语义时加 `--verbose` 收集诊断后停止
|
||||
2. 若 stderr 出现 `RECOVERY_EVENT_ID=<event_id>`,优先按 [recovery-guide.md](./references/recovery-guide.md) 执行 recovery 闭环
|
||||
3. 仍然失败,报告完整错误信息给用户,禁止自行尝试替代方案
|
||||
4. 认证失败时,参考 [global-reference.md](./references/global-reference.md) 中的认证章节处理
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
|
||||
| Recipe | 行动指南(固定路线) |
|
||||
|--------|-------------------|
|
||||
| query-group-chat | **优先**:`chat_export_messages.py`(开源版未引入;可手动用 `dws chat message list` 翻页后写入文件)(自动搜群+翻页+导出)<br>备选:1. `chat search --query "<群名>"` → 取 `openConversationId`<br>2. `chat message list --group <openConversationId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表<br>3. **翻页**:`hasMore=true` 时取本页最后 `createTime` 作为下次 `--time`,重复至 `hasMore=false`<br>4. `--forward=false` 拉给定时间**之前**的消息<br>5. 合并全部消息后总结 |
|
||||
| query-private-chat | **优先**:`chat_history_with_user.py`(开源版未引入;可手动用 `dws chat search` + `dws chat message list` 组合)(自动搜人+翻页+导出)<br>备选:1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`<br>2. `chat message list --user <userId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表<br>3. **翻页**:同 query-group-chat<br>4. 合并全部消息后总结 |
|
||||
| escalate-ding | 三级升级:<br>1. `ding message send --robot-code <robotCode> --type app --users <userId> --content "<内容>"`(必填项见 [ding.md](../products/ding.md))<br>2. `chat message send --group <openConversationId> --text "<内容>"` 群里提醒(可选 `--title` / `@` 见 [chat.md](../products/chat.md))<br>3. `todo task create --title "<标题>" --executors <userId> --priority 40` 建紧急待办<br>前置:`aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`;`chat search --query "<群名>"` → 取 `openConversationId` |
|
||||
| send-by-bot | **多群批量优先**:`bot_broadcast.py`(开源版未引入;可手动用 `dws chat message send-by-bot` 多次调用)<br>单群:1. `chat bot search` → 取 `robotCode`<br>2. `chat search --query "<群名>"` → 取 `openConversationId`<br>3. `chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title "<标题>" --text "<内容>"` |
|
||||
| forward-message | 1. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message list --group <openConversationId> --time "<起始时间>"` 拉源消息<br>2. `contact user search --query "<姓名>"` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取目标 `openConversationId`<br>3. `chat message send --open-dingtalk-id <openDingTalkId> --text "<内容>"`(推荐)或 `--group <openConversationId> --text "<内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user <userId>`(备选) |
|
||||
| query-group-chat | `dws chat +chat-messages --group "<群名>" --time "<yyyy-MM-dd HH:mm:ss>" --page-all --output ./exports/messages.json --format json`;只接受唯一解析结果,并检查 `complete/hasMore/failures`。 |
|
||||
| query-private-chat | 先解析唯一用户 ID,再执行 `dws chat +chat-messages --user "<userId>" --time "<yyyy-MM-dd HH:mm:ss>" --page-all --format json`;零命中/多候选停止消歧。 |
|
||||
| escalate-ding | 三级升级:<br>1. `ding message send --robot-code <robotCode> --type app --users <userId> --content "<内容>"`(必填项见 [ding.md](../products/ding.md))<br>2. `dws chat +messages-send --as user --group <openConversationId> --text "<内容>" --format json` 群里提醒;Runtime 会在缺少确认时停止,必须先完成收件人、内容与影响确认,再以相同参数追加 `--yes`<br>3. `todo task create --title "<标题>" --executors <userId> --priority 40` 建紧急待办<br>所有稳定 ID 来自同一 profile 的真实返回,不选搜索第一项。 |
|
||||
| send-by-bot | 单群用 `--group`;多群用 `dws chat +messages-send --as bot --robot-code <robotCode> --groups <cid1,cid2> --title "<标题>" --text "<内容>" --format json`。Runtime 要求先确认再追加 `--yes`,并去重、返回 `im.batch-write.v1` 逐目标 ledger;未知投递状态时停止重发。 |
|
||||
| forward-message | 1. `dws chat +chat-messages --group "<源群名>" --format json` 取得唯一源会话与真实 `openMessageId`<br>2. 从真实返回或唯一解析取得目标 `openConversationId`<br>3. `dws chat +messages-forward --src-conversation-id <srcCid> --msg-id <openMessageId> --dest-conversation-id <destCid> --format json`;Runtime 会先停止并要求确认,确认后以相同参数追加 `--yes` 执行并保留幂等键。若用户只是要转述内容而非转发原消息,改走 `+dm`/`+send-to-group`。 |
|
||||
| search-common-group | `chat search-common --nicks "<昵称1>,<昵称2>" --limit 20 --cursor 0`(`--match-mode AND`=全在/`OR`=任一在,翻页:`hasMore=true` 时用 `nextCursor`)<br>用户说"我和XX的共同群" → nicks 包含"我"时,需先 `contact user get-self` 取自己昵称再拼接 |
|
||||
| focus-messages | **零参数一行命令**:`chat message list-focused --limit 50`(拉特别关注人发的消息聚合)<br>触发 query:`"我特别关注的人最近发了什么消息"`、`"关注的人最近聊了啥"`、`"星标联系人最近的动态"`<br>**强消歧**:query 含动词【发/说/聊/讲】或名词【消息/聊天/动态】 → **必须**走本命令,**不要**先去拉 `contact relation list-my-followings`;仅当用户终点是"人员列表"(如"我关注了谁")才走 `relation list-my-followings`(详见 [contact.md](../products/contact.md#意图判断) 易混淆硬规则)<br>翻页:`hasMore=true` 时用 `nextCursor` 作为下次 `--cursor`<br>按人精控(可选):先 `contact relation list-my-followings` 取 `openDingTalkId`,再 `chat message list-by-sender --sender-open-dingtalk-id <openDingTalkId> --start <ISO> --end <ISO>` |
|
||||
|
||||
@@ -8,15 +8,16 @@
|
||||
|
||||
| 意图 | 首选 |
|
||||
|---|---|
|
||||
| 以 current-user / bot / webhook 身份发消息 | `dws chat +messages-send --as <identity> ...` |
|
||||
| 拉取单个群聊或单聊的消息 | `dws chat +chat-messages ...` |
|
||||
| 以 current-user / bot / webhook 身份发消息 | `dws chat +messages-send --as <identity> ...`;Bot 多群用 `--groups/--groups-file` |
|
||||
| 拉取单个群聊或单聊的消息 | `dws chat +chat-messages ...`;全量加 `--page-all`,导出加 `--output` |
|
||||
| 按关键词、发送者、@对象、会话、类型或时间组合搜索 | `dws chat +search-msg ...` |
|
||||
| 查询 @我的消息 | `dws chat +at-me ...` |
|
||||
| 根据消息 ID 批量取详情与 reaction | `dws chat +messages-mget ...` |
|
||||
| 读取已知 thread/topic 的全部回复 | `dws chat +thread-replies ...` |
|
||||
| 下载单个 mediaId/fileId | `dws chat +messages-resource-download ...` |
|
||||
|
||||
- `+messages-send` 只暴露下层真实支持的身份能力:user 支持文本/Markdown、已有 mediaId 图片、本地文件和幂等键;bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。
|
||||
- `+messages-send` 只暴露下层真实支持的身份能力:user 支持文本/Markdown、已有 mediaId 图片、本地文件和幂等键;bot 支持群聊、最多 100 个稳定群 ID 的逐项 ledger 或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。Bot/Webhook 不支持富媒体。
|
||||
- `+chat-messages --page-all` 连续读取 typed `nextPage.time`,按消息 ID 去重并受 `--page-limit/--max-results` 约束;`--output` 将同一完整性 ledger 原子写入工作目录内 JSON。
|
||||
- `+messages-send` 会自动规范化并补齐 @ 占位符。user 使用 `<@id>` / `<@all>`;bot/webhook 使用 `@id` / `@手机号` / `@all`。声明 `--at-*` / `--at-all` 即可,不要为统一 Shortcut 手工拼 `@10`。
|
||||
- `+search-msg --page-all` 连续翻页并默认按消息 ID 批量富化;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger。
|
||||
- `+at-me`、`+chat-messages`、`+messages-mget`、`+search-msg`、`+thread-replies` 可用 `--download-resources` 下载资源。引用、回复、合并转发中的资源使用结果 `resourceRefs` 自带的子消息 `messageId`;仅当子消息缺会话 ID 时继承父消息 `openConversationId`。
|
||||
@@ -150,8 +151,7 @@ Usage:
|
||||
dws chat group upgrade-to-external [flags]
|
||||
Example:
|
||||
dws chat group upgrade-to-external --group <openConversationId> --dry-run
|
||||
dws chat group upgrade-to-external --group <openConversationId> --yes
|
||||
dws chat group upgrade-to-external --group <openConversationId> --extension '{"source":"dws"}' --yes
|
||||
dws chat group upgrade-to-external --group <openConversationId> --extension '{"source":"dws"}' --dry-run
|
||||
Flags:
|
||||
--group string 待升级普通群的 openConversationId (必填)
|
||||
--extension string 预留扩展字段 JSON 对象;对象值必须是字符串 (可选)
|
||||
@@ -1647,7 +1647,7 @@ Flags:
|
||||
|
||||
#### 关闭/开启 @所有人消息提醒 — 关闭或开启会话中 @所有人的消息通知
|
||||
|
||||
> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。命令本身参数合法,但调用不会生效。
|
||||
> 前置条件:先为会话开启总免打扰(`dws chat mute --conversation-id <openConversationId>`),否则平台返回 `NotificationOffNotEnabled`。
|
||||
```
|
||||
Usage:
|
||||
dws chat mute-at-all [flags]
|
||||
@@ -1663,6 +1663,7 @@ Flags:
|
||||
|
||||
注意:
|
||||
- 默认行为是关闭 @所有人通知,传 --off 则恢复接收通知
|
||||
- 该子开关依赖总免打扰;恢复 @所有人通知后,再修改红包子开关前应重新开启总免打扰
|
||||
- 支持单聊和群聊,openConversationId 可通过 chat search(群聊)或 chat conversation-info(单聊)获取
|
||||
```
|
||||
|
||||
@@ -1670,7 +1671,7 @@ Flags:
|
||||
|
||||
#### 关闭/开启红包消息提醒 — 关闭或开启会话中的红包消息通知
|
||||
|
||||
> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。命令本身参数合法,但调用不会生效。
|
||||
> 前置条件:先为会话开启总免打扰(`dws chat mute --conversation-id <openConversationId>`),否则平台返回 `NotificationOffNotEnabled`。
|
||||
```
|
||||
Usage:
|
||||
dws chat mute-red-envelope [flags]
|
||||
@@ -1686,6 +1687,7 @@ Flags:
|
||||
|
||||
注意:
|
||||
- 默认行为是关闭红包通知,传 --off 则恢复接收通知
|
||||
- 若刚恢复了 @所有人通知,应先重新开启总免打扰,再修改红包通知
|
||||
- 支持单聊和群聊,openConversationId 可通过 chat search(群聊)或 chat conversation-info(单聊)获取
|
||||
```
|
||||
|
||||
@@ -2019,8 +2021,8 @@ Flags:
|
||||
- `chat category list-by-conv` / `chat category batch-info` — 查询会话所属分组 / 批量查询分组信息
|
||||
- `chat mute` — 开启/关闭会话消息免打扰(默认开启,--off 关闭)
|
||||
- `chat hide` — 在会话列表中隐藏会话(支持单聊/群聊,收到新消息时重新出现)
|
||||
- `chat mute-at-all` — 关闭/开启 @所有人消息提醒(默认关闭,--off 恢复)
|
||||
- `chat mute-red-envelope` — 关闭/开启红包消息提醒(默认关闭,--off 恢复)
|
||||
- `chat mute-at-all` — 关闭/开启 @所有人消息提醒(默认关闭,--off 恢复;需先开启总免打扰)
|
||||
- `chat mute-red-envelope` — 关闭/开启红包消息提醒(默认关闭,--off 恢复;需先开启总免打扰)
|
||||
- `chat mark-unread` / `chat mark-read` — 标记会话未读 / 标记指定消息及之前的消息已读
|
||||
- `chat clear-red-point` / `chat clear-all-red-point` — 清除单个会话红点 / 一键清除所有会话红点(全部已读)
|
||||
- `chat list-all-conversations` — 分页拉取当前用户全部会话列表(单聊+群聊,与 list-top-conversations 的区别是不限置顶)
|
||||
@@ -2307,8 +2309,8 @@ Flags:
|
||||
- `chat category list` 无需参数;`category list-conversations` 需传 --category-id(通过 category list 获取)
|
||||
- `chat mute` 默认开启免打扰,传 --off 关闭;--conversation-id / --id / --chat 三个别名均可用于传入会话 ID
|
||||
- `chat hide` 隐藏会话,需传 --conversation-id(openConversationId,支持单聊/群聊),隐藏后不显示在列表中,收到新消息时重新出现
|
||||
- `chat mute-at-all` 关闭/开启 @所有人消息提醒,需传 --conversation-id(openConversationId),默认关闭通知,传 --off 恢复接收
|
||||
- `chat mute-red-envelope` 关闭/开启红包消息提醒,需传 --conversation-id(openConversationId),默认关闭通知,传 --off 恢复接收
|
||||
- `chat mute-at-all` 关闭/开启 @所有人消息提醒,需传 --conversation-id(openConversationId),默认关闭通知,传 --off 恢复接收;调用前需先开启总免打扰
|
||||
- `chat mute-red-envelope` 关闭/开启红包消息提醒,需传 --conversation-id(openConversationId),默认关闭通知,传 --off 恢复接收;调用前需先开启总免打扰
|
||||
- `chat message reply` 引用回复消息(**单聊/群聊均可**),需传 --conversation-id(openConversationId,单聊与群聊使用同一字段)、--ref-msg-id(被引用消息 openMessageId)、--ref-sender(被引用消息发送者 openDingTalkId)、--text(回复内容);目前回复类型仅支持 text
|
||||
- `chat message forward` 转发单条消息(**源/目标会话均支持单聊/群聊**,常见组合:群→群、群→单、单→群、单→单),需传 --src-conversation-id(源会话 openConversationId)、--msg-id(源消息 openMessageId)、--dest-conversation-id(目标会话 openConversationId)
|
||||
- `chat set-top` 设置/取消会话置顶(**单聊/群聊均可**),需传 --conversation-id(openConversationId,单聊与群聊使用同一字段),默认置顶,传 --off 取消
|
||||
@@ -2320,12 +2322,11 @@ Flags:
|
||||
- `chat group-mute-member` 指定群成员禁言,需传 --group、--user/--users(userId,逗号分隔)、--mute-time(毫秒,仅禁言时必填,支持 300000/3600000/86400000/604800000/2592000000),传 --off 解除禁言;CLI 会自动把 userId 解析成 openDingTalkId 再调用,直接传 userId 即可;禁言群主会被服务端拒绝
|
||||
- `chat group set-admin` 设置/取消群管理员,需传 --group(openConversationId)、--user/--users(userId,逗号分隔),默认设为管理员,传 --off 取消
|
||||
|
||||
## 自动化脚本
|
||||
## Runtime 替代旧 Chat 脚本
|
||||
|
||||
| 脚本 | 场景 | 用法 |
|
||||
|------|------|------|
|
||||
| [chat_export_messages.py](../../scripts/chat_export_messages.py) | 导出群聊消息到 JSON 文件 | `python chat_export_messages.py --query "项目冲刺" --time "2026-03-10 00:00:00"` |
|
||||
| [chat_history_with_user.py](../../scripts/chat_history_with_user.py) | 查询与某人的单聊聊天记录 | `python chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"` |
|
||||
- 群聊/单聊全量导出:`dws chat +chat-messages --page-all --output <相对.json>`;目标可用稳定 ID 或唯一自然查询。
|
||||
- Bot 多群广播:`dws chat +messages-send --as bot --groups ...` 或 `--groups-file ...`;输出 `im.batch-write.v1` 逐项 ledger。
|
||||
- 旧 Chat 导出与广播脚本已停止发布,不能在执行计划中引用。
|
||||
|
||||
## 相关产品
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# dws event — 个人 IM 事件
|
||||
|
||||
通过个人 Stream 长连接监听当前用户的钉钉消息接收、全量消息、已读、撤回、表情回应和群生命周期事件,NDJSON 输出到 stdout,用于驱动事件触发的 Agent。实时监听、自动回复、订阅事件都必须使用 `dws event consume`,不要写脚本轮询消息历史。
|
||||
通过个人 Stream 长连接监听当前用户的钉钉消息接收、已读、撤回、表情回应和群生命周期事件,NDJSON 输出到 stdout,用于驱动事件触发的 Agent。普通 IM 监听默认使用 `dws event +listen-im`;群生命周期、显式 EventKey、Filter DSL、subscribe_id 复用或原始 envelope 才使用 `dws event consume`。不要写脚本轮询消息历史。
|
||||
|
||||
## 运行方式
|
||||
|
||||
- bus 后台进程持有对钉钉的个人 Stream 长连;consume 从 bus 读事件、按 NDJSON 打到 stdout。consume 只读,不发消息(回复用 `dws chat message send`)。
|
||||
- 没有 bus 时 consume 自动拉起;通常只跑 consume。
|
||||
- bus 后台进程持有对钉钉的个人 Stream 长连;`+listen-im` 把高层意图编译为 EventKey,再复用 consume 的订阅、ready、NDJSON、取消、回滚和清理生命周期。监听本身不发消息;回复使用 `dws chat +messages-send`。
|
||||
- 没有 bus 时监听命令自动拉起;普通任务只跑 `+listen-im`。
|
||||
- 一个组织一个 bus,互不干扰、可同时跑;同组织内多个 consume 共享一个 bus。
|
||||
- 非默认组织加全局 `--profile <corpId 或 profile 名>`;漏传会退回默认 profile 而失败。
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `dws event +listen-im --kind ... [flags]` | 普通 message/reaction/read/recall 监听;支持自然姓名/群名唯一解析 |
|
||||
| `dws event schema <event_key> --flatten` | 查看 Agent 使用的顶层业务字段 schema |
|
||||
| `dws event consume <event_key> [event_key...] --flatten [flags]` | 阻塞消费一个或多个兼容事件,事件写到 stdout,用 `-f ndjson` |
|
||||
| `dws event consume <event_key> [event_key...] --flatten [flags]` | 高级入口:阻塞消费显式 EventKey,事件写到 stdout,用 `-f ndjson` |
|
||||
| `dws event status --event <event_key>` | 查看个人订阅、bus、本地 consume |
|
||||
| `dws event stop <subscribe_id> --dry-run` / `--yes` | 先预览,再确认取消订阅并停止对应本地消费 |
|
||||
| `dws event stop --all --dry-run` / `--yes` | 先预览,再确认清理当前身份下全部个人订阅 |
|
||||
@@ -48,27 +49,22 @@
|
||||
|
||||
| 用户说 | 下一步 |
|
||||
|---|---|
|
||||
| "监听有人 @ 我的消息" | `event consume`,事件码 `user_im_message_receive_at`,参数 `--flatten -f ndjson` |
|
||||
| "监听我和 userId test-user-001 的单聊消息" | `event consume`,事件码 `user_im_message_receive_o2o`,参数 `--user test-user-001 --flatten -f ndjson` |
|
||||
| "监听我和 openDingtalkId abc 的单聊消息" | `event consume`,事件码 `user_im_message_receive_o2o`,参数 `--open-dingtalk-id abc --flatten -f ndjson` |
|
||||
| "监听 XX 群消息" | 先 `dws chat search --query "XX" --format json`,确认后 consume group |
|
||||
| "监听 userId test-user-001 发给我的消息" | `event consume`,事件码 `user_im_message_receive_user`,参数 `--user test-user-001 --flatten -f ndjson` |
|
||||
| "监听 openDingtalkId abc 发给我的消息" | `event consume`,事件码 `user_im_message_receive_user`,参数 `--open-dingtalk-id abc --flatten -f ndjson` |
|
||||
| "监听我的所有单聊消息" | `event consume`,事件码 `user_im_message_receive_o2o_all`,参数 `--flatten -f ndjson` |
|
||||
| "监听我所在的所有群消息" | `event consume`,事件码 `user_im_message_receive_group_all`,参数 `--flatten -f ndjson` |
|
||||
| "监听我发给 userId test-user-001 的消息是否已读" | `event consume`,事件码 `user_im_message_read_o2o`,参数 `--user test-user-001 --flatten -f ndjson` |
|
||||
| "监听 XX 群消息已读" | 先解析群 ID,再 consume `user_im_message_read_group --group <id>` |
|
||||
| "监听我和 userId test-user-001 的消息撤回" | `event consume`,事件码 `user_im_message_recall_o2o`,参数 `--user test-user-001 --flatten -f ndjson` |
|
||||
| "监听 XX 群消息撤回" | 先解析群 ID,再 consume `user_im_message_recall_group --group <id>` |
|
||||
| "监听我和 userId test-user-001 的消息贴表情" | `event consume`,事件码 `user_im_message_reaction_o2o`,参数 `--user test-user-001 --flatten -f ndjson` |
|
||||
| "监听 XX 群消息表情回应" | 先解析群 ID,再 consume `user_im_message_reaction_group --group <id>` |
|
||||
| "监听有人 @ 我的消息" | `dws event +listen-im --kind at-me -f ndjson` |
|
||||
| "监听我和 userId test-user-001 的单聊消息" | `dws event +listen-im --kind sender --user test-user-001 -f ndjson` |
|
||||
| "监听我和 openDingtalkId abc 的单聊消息" | `dws event +listen-im --kind sender --open-dingtalk-id abc -f ndjson` |
|
||||
| "监听 XX 群消息" | `dws event +listen-im --kind group --chat-query "XX" -f ndjson`;多候选停止消歧 |
|
||||
| "监听某人发给我的消息" | `dws event +listen-im --kind sender --user-query "<姓名>" -f ndjson` |
|
||||
| "监听我的所有单聊消息" | `dws event +listen-im --kind all-direct -f ndjson` |
|
||||
| "监听我所在的所有群消息" | `dws event +listen-im --kind all-group -f ndjson` |
|
||||
| "监听某人的消息已读/撤回/reaction" | `dws event +listen-im --kind sender --user-query "<姓名>" --events read,recall,reaction -f ndjson` |
|
||||
| "监听 XX 群消息已读/撤回/reaction" | `dws event +listen-im --kind group --chat-query "XX" --events read,recall,reaction -f ndjson` |
|
||||
| "监听 XX 群改名" | 先解析群 ID,再 consume `user_im_group_updated --group <id>` |
|
||||
| "监听有人加入 XX 群" | 先解析群 ID,再 consume `user_im_group_member_added --group <id>` |
|
||||
| "监听有人退出 XX 群" | 先解析群 ID,再 consume `user_im_group_member_exited --group <id>` |
|
||||
| "监听 XX 群解散" | 先解析群 ID,再 consume `user_im_group_disbanded --group <id>`;破坏性自测只能用测试群 |
|
||||
| "监听并自动回复某人的单聊消息" | 先解析对端 userId,再启动 o2o consume;不要写轮询脚本 |
|
||||
| "同时监听同一人的单聊、已读和撤回" | 一个 consume 放入 3 个 event key,共享同一个 `--user` |
|
||||
| "同时监听同一群的消息、改名和解散" | 一个 consume 放入 3 个 event key,共享同一个 `--group` |
|
||||
| "监听并自动回复某人的单聊消息" | `+listen-im --kind sender --user-query "<姓名>"`;事件到达后把稳定 ID 交给 `chat +messages-send` |
|
||||
| "同时监听同一人的消息、已读和撤回" | 一个 `+listen-im --kind sender --events message,read,recall` 生命周期 |
|
||||
| "同时监听同一群的消息、改名和解散" | 生命周期事件不在 Shortcut 范围;一个高级 consume 放入 3 个 EventKey,共享 `--group` |
|
||||
| "查看个人消息事件 schema" | `dws event schema <event_key> --flatten` |
|
||||
| "看个人事件订阅状态" | `dws event status --event <event_key>` |
|
||||
| "停止这个个人事件订阅" | `dws event stop <subscribe_id> --dry-run`,确认后改用 `--yes` |
|
||||
@@ -79,15 +75,26 @@
|
||||
|
||||
## Call flow
|
||||
|
||||
1. 从用户意图选择事件码;人名或群名先解析成必填 ID。
|
||||
1. 普通 IM 意图选择 `+listen-im` 的 `--kind` 与 `--events`;人名/群名直接用 `--user-query`/`--chat-query` 唯一解析。高级事件控制才手选 EventKey。
|
||||
2. 需要了解字段时运行 `dws event schema <event_key> --flatten`,读取 `schema.properties`;此模式的 `jq_root_path` 为 `.`。
|
||||
3. 启动 `dws event consume <event_key> [event_key...] ... --flatten -f ndjson`。单事件等待 `ready event_key=...`;多事件记录每条 `subscription event_key=... subscribe_id=...`,再等待整体 `ready event_count=...`。不要用 `sleep` 猜测。
|
||||
3. 普通任务启动 `dws event +listen-im ... -f ndjson`;高级任务启动 `dws event consume <event_key> [event_key...] ... --flatten -f ndjson`。等待真实 ready marker;多事件记录每条 subscription,再等待整体 ready。不要用 `sleep` 猜测。
|
||||
4. stdout 每行是一个扁平事件 JSON;消息、动作及群成员加入/退出事件读取顶层业务字段。群标题变更和群解散只读取公共字段和 `payload` 中实际存在的字段。
|
||||
5. 需要确认监听状态时运行 `dws event status --event <event_key>`,查看 `Subscriptions` 和 `Consumers`。
|
||||
6. 任务完成后优雅结束 consume;本次新建的订阅会自动取消。复用已有订阅或需要从外部主动取消时,先运行 `dws event stop <subscribe_id> --dry-run`,向用户确认后再以 `--yes` 执行;自测可在 consume 加 `--max-events` 或 `--duration` 自动退出。
|
||||
|
||||
## Commands
|
||||
|
||||
普通 IM 监听:
|
||||
|
||||
```bash
|
||||
dws event +listen-im --kind at-me -f ndjson
|
||||
dws event +listen-im --kind sender --user-query "张三" --events message,reaction -f ndjson
|
||||
dws event +listen-im --kind group --chat-query "项目冲刺" --events message,read,recall -f ndjson
|
||||
dws event +listen-im --kind all-direct --max-events 10 -f ndjson
|
||||
```
|
||||
|
||||
以下 EventKey 与 consume 命令是高级控制面:
|
||||
|
||||
```bash
|
||||
dws event schema user_im_message_receive_at --flatten
|
||||
dws event schema user_im_message_receive_o2o --flatten
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
用机器人向多个群批量发送相同消息(如日报提醒)
|
||||
|
||||
用法:
|
||||
python bot_broadcast.py \
|
||||
--robot-code <ROBOT_CODE> \
|
||||
--chats "conv_id1,conv_id2,conv_id3" \
|
||||
--title "日报提醒" \
|
||||
--text "请大家今天下班前提交日报"
|
||||
|
||||
python bot_broadcast.py \
|
||||
--robot-code <ROBOT_CODE> \
|
||||
--chats-file groups.txt \
|
||||
--title "周会通知" \
|
||||
--text "明天下午3点周会"
|
||||
|
||||
python bot_broadcast.py --dry-run ...
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import List, Any, Optional
|
||||
|
||||
|
||||
def run_dws(
|
||||
args: List[str], dry_run: bool = False,
|
||||
) -> Optional[Any]:
|
||||
cmd = ['dws'] + args
|
||||
if dry_run:
|
||||
print(f"[dry-run] {' '.join(cmd)}")
|
||||
return {'dry_run': True}
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=60
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f" ✗ 错误:{result.stderr.strip()}")
|
||||
return None
|
||||
return json.loads(result.stdout)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError,
|
||||
FileNotFoundError) as e:
|
||||
print(f" ✗ 错误:{e}")
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='向多个群批量发送机器人消息'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--robot-code', required=True, help='机器人 Code'
|
||||
)
|
||||
parser.add_argument('--chats', default='', help='会话 ID 列表')
|
||||
parser.add_argument(
|
||||
'--chats-file', default='',
|
||||
help='会话 ID 文件 (每行一个)',
|
||||
)
|
||||
parser.add_argument('--title', required=True, help='消息标题')
|
||||
parser.add_argument(
|
||||
'--text', required=True, help='消息内容 Markdown'
|
||||
)
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
args = parser.parse_args()
|
||||
|
||||
chat_ids: List[str] = []
|
||||
if args.chats:
|
||||
chat_ids = [c.strip() for c in args.chats.split(',')
|
||||
if c.strip()]
|
||||
elif args.chats_file:
|
||||
p = Path(args.chats_file)
|
||||
if not p.exists():
|
||||
print(f"错误:文件不存在: {p}")
|
||||
sys.exit(1)
|
||||
chat_ids = [line.strip() for line in
|
||||
p.read_text(encoding='utf-8').splitlines()
|
||||
if line.strip() and not line.startswith('#')]
|
||||
if not chat_ids:
|
||||
print('错误:需要 --chats 或 --chats-file')
|
||||
sys.exit(1)
|
||||
|
||||
print(f"📢 批量发送消息到 {len(chat_ids)} 个群")
|
||||
print(f" 标题: {args.title}")
|
||||
print(f" 机器人: {args.robot_code}")
|
||||
print('=' * 50)
|
||||
|
||||
success, fail = 0, 0
|
||||
for i, chat_id in enumerate(chat_ids, 1):
|
||||
result = run_dws([
|
||||
'chat', 'message', 'send-by-bot',
|
||||
'--robot-code', args.robot_code,
|
||||
'--group', chat_id,
|
||||
'--title', args.title,
|
||||
'--text', args.text,
|
||||
'--format', 'json',
|
||||
], dry_run=args.dry_run)
|
||||
if result:
|
||||
print(f" ✓ [{i}/{len(chat_ids)}] {chat_id}")
|
||||
success += 1
|
||||
else:
|
||||
print(f" ✗ [{i}/{len(chat_ids)}] {chat_id}")
|
||||
fail += 1
|
||||
|
||||
print(f"\n完成: 成功 {success}, 失败 {fail}")
|
||||
sys.exit(0 if fail == 0 else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,194 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
导出群聊消息到 JSON 文件(从指定时间点拉取)
|
||||
|
||||
用法:
|
||||
python chat_export_messages.py \
|
||||
--group <openconversation_id> \
|
||||
--time "2026-03-10 00:00:00" \
|
||||
--output messages.json
|
||||
|
||||
python chat_export_messages.py \
|
||||
--query "项目冲刺" \
|
||||
--time "2026-03-10 00:00:00" \
|
||||
--no-forward --limit 100
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import argparse
|
||||
from typing import List, Any, Optional
|
||||
|
||||
|
||||
def run_dws(
|
||||
args: List[str], dry_run: bool = False,
|
||||
) -> Optional[Any]:
|
||||
cmd = ['dws'] + args
|
||||
if dry_run:
|
||||
print(f"[dry-run] {' '.join(cmd)}")
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=120
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
|
||||
return None
|
||||
return json.loads(result.stdout)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError,
|
||||
FileNotFoundError) as e:
|
||||
print(f"错误:{e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def search_group(
|
||||
query: str, dry_run: bool = False,
|
||||
) -> Optional[str]:
|
||||
data = run_dws([
|
||||
'chat', 'search',
|
||||
'--query', query, '--format', 'json',
|
||||
], dry_run=dry_run)
|
||||
if dry_run:
|
||||
return '<CONV_ID>'
|
||||
if not data:
|
||||
return None
|
||||
if isinstance(data, list):
|
||||
groups = data
|
||||
elif isinstance(data, dict):
|
||||
inner = data.get('result', data)
|
||||
if isinstance(inner, dict):
|
||||
groups = inner.get('items', inner.get('groups', []))
|
||||
elif isinstance(inner, list):
|
||||
groups = inner
|
||||
else:
|
||||
groups = []
|
||||
else:
|
||||
groups = []
|
||||
if not groups:
|
||||
print(f"未找到群聊: {query}")
|
||||
return None
|
||||
g = groups[0]
|
||||
name = g.get('title') or g.get('name', '未知')
|
||||
conv_id = g.get('openConversationId') or g.get('id')
|
||||
print(f" 找到群聊: {name} ({conv_id})")
|
||||
return conv_id
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='导出群聊消息到 JSON'
|
||||
)
|
||||
parser.add_argument('--group', help='群聊 openconversation_id')
|
||||
parser.add_argument('--query', help='按群名搜索')
|
||||
parser.add_argument(
|
||||
'--time', required=True,
|
||||
help='起始时间 yyyy-MM-dd HH:mm:ss',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-forward', action='store_true',
|
||||
help='拉给定时间之前的消息 (默认拉给定时间之后)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--limit', type=int, default=0,
|
||||
help='返回条数 (不传则不限制)',
|
||||
)
|
||||
parser.add_argument('--output', default='', help='输出文件')
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
args = parser.parse_args()
|
||||
|
||||
conv_id = args.group
|
||||
if not conv_id:
|
||||
if not args.query:
|
||||
print('错误:需要 --group 或 --query 参数')
|
||||
sys.exit(1)
|
||||
print(f'🔍 搜索群聊: {args.query}')
|
||||
conv_id = search_group(args.query, args.dry_run)
|
||||
if not conv_id and not args.dry_run:
|
||||
sys.exit(1)
|
||||
|
||||
print(f'📥 拉取消息 (起始: {args.time})...')
|
||||
all_messages: List[Any] = []
|
||||
current_time = args.time
|
||||
page = 0
|
||||
max_pages = 50
|
||||
remaining = args.limit if args.limit > 0 else float('inf')
|
||||
|
||||
while page < max_pages and remaining > 0:
|
||||
cmd_args = [
|
||||
'chat', 'message', 'list',
|
||||
'--group', conv_id or '<CONV_ID>',
|
||||
'--time', current_time,
|
||||
'--format', 'json',
|
||||
]
|
||||
if args.no_forward:
|
||||
cmd_args.append('--forward=false')
|
||||
page_limit = min(int(remaining), 200) if args.limit > 0 else 0
|
||||
if page_limit > 0:
|
||||
cmd_args.extend(['--limit', str(page_limit)])
|
||||
data = run_dws(cmd_args, dry_run=args.dry_run)
|
||||
|
||||
if args.dry_run:
|
||||
print('[dry-run] 翻页循环: hasMore → 继续用边界 createTime 作为 --time')
|
||||
return
|
||||
|
||||
if not data:
|
||||
break
|
||||
|
||||
# 兼容两种结构: 顶层 messages / {result: {messages, hasMore}}
|
||||
if isinstance(data, list):
|
||||
page_msgs = data
|
||||
has_more = False
|
||||
else:
|
||||
container = data
|
||||
inner = data.get('result')
|
||||
if isinstance(inner, dict):
|
||||
container = inner
|
||||
page_msgs = container.get('messages')
|
||||
if page_msgs is None and isinstance(inner, list):
|
||||
page_msgs = inner
|
||||
if not isinstance(page_msgs, list):
|
||||
page_msgs = []
|
||||
has_more = bool(container.get('hasMore', False))
|
||||
|
||||
if not page_msgs:
|
||||
break
|
||||
|
||||
all_messages.extend(page_msgs)
|
||||
remaining -= len(page_msgs)
|
||||
page += 1
|
||||
|
||||
if not has_more:
|
||||
break
|
||||
|
||||
last_msg = page_msgs[-1]
|
||||
boundary_time = (last_msg.get('createTime')
|
||||
or last_msg.get('createAt')
|
||||
or last_msg.get('time', ''))
|
||||
if not boundary_time or boundary_time == current_time:
|
||||
break
|
||||
current_time = boundary_time
|
||||
print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
|
||||
|
||||
if not all_messages:
|
||||
print('未拉取到消息')
|
||||
return
|
||||
|
||||
if args.output:
|
||||
with open(args.output, 'w', encoding='utf-8') as f:
|
||||
json.dump(all_messages, f, ensure_ascii=False, indent=2)
|
||||
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
|
||||
else:
|
||||
for m in all_messages:
|
||||
# 实际字段: sender/createTime/content (兼容旧字段名)
|
||||
sender = (m.get('sender') or m.get('senderNick')
|
||||
or m.get('senderOpenDingTalkId', '未知'))
|
||||
text = m.get('content') or m.get('text', '')
|
||||
time_str = (m.get('createTime') or m.get('createAt')
|
||||
or m.get('time', ''))
|
||||
print(f" [{time_str}] {sender}: {text[:80]}")
|
||||
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,195 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
查询与某人的单聊聊天记录
|
||||
|
||||
用法:
|
||||
python chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"
|
||||
python chat_history_with_user.py --user <userId> --time "2026-03-10 00:00:00" --limit 50
|
||||
python chat_history_with_user.py --name "张三" --time "2026-03-01 00:00:00" --output history.json
|
||||
|
||||
工作流:
|
||||
1. 通过 --name 搜索通讯录,获取 userId(或直接传 --user)
|
||||
2. 调用 chat message list --user <userId> 拉取单聊消息
|
||||
3. 输出到终端或导出为 JSON 文件
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import argparse
|
||||
from typing import List, Any, Optional
|
||||
|
||||
|
||||
def run_dws(
|
||||
args: List[str], dry_run: bool = False,
|
||||
) -> Optional[Any]:
|
||||
"""执行 dws 命令并解析 JSON 输出"""
|
||||
cmd = ['dws'] + args
|
||||
if dry_run:
|
||||
print(f"[dry-run] {' '.join(cmd)}")
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=120
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
|
||||
return None
|
||||
return json.loads(result.stdout)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError,
|
||||
FileNotFoundError) as e:
|
||||
print(f"错误:{e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def search_user(
|
||||
name: str, dry_run: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""按关键词搜索用户,返回第一个匹配的 userId"""
|
||||
data = run_dws([
|
||||
'contact', 'user', 'search',
|
||||
'--query', name, '--format', 'json',
|
||||
], dry_run=dry_run)
|
||||
if dry_run:
|
||||
return '<USER_ID>'
|
||||
if not data:
|
||||
return None
|
||||
# 解析返回结构
|
||||
users = data
|
||||
if isinstance(data, dict):
|
||||
inner = data.get('result', data)
|
||||
if isinstance(inner, dict):
|
||||
users = (inner.get('users', [])
|
||||
or inner.get('list', []))
|
||||
elif isinstance(inner, list):
|
||||
users = inner
|
||||
else:
|
||||
users = []
|
||||
if not users or not isinstance(users, list):
|
||||
print(f"未找到用户: {name}")
|
||||
return None
|
||||
u = users[0]
|
||||
user_name = u.get('name') or u.get('nick', '未知')
|
||||
user_id = u.get('userId') or u.get('userid', '')
|
||||
print(f" 找到用户: {user_name} ({user_id})")
|
||||
return user_id
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='查询与某人的单聊聊天记录'
|
||||
)
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument('--name', help='按姓名搜索用户')
|
||||
group.add_argument('--user', help='直接指定 userId')
|
||||
parser.add_argument(
|
||||
'--time', required=True,
|
||||
help='起始时间 yyyy-MM-dd HH:mm:ss',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-forward', action='store_true',
|
||||
help='拉给定时间之前的消息 (默认拉给定时间之后)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--limit', type=int, default=0,
|
||||
help='返回条数 (不传则不限制)',
|
||||
)
|
||||
parser.add_argument('--output', default='', help='导出到 JSON 文件')
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
args = parser.parse_args()
|
||||
|
||||
# 1. 获取 userId
|
||||
user_id = args.user
|
||||
if not user_id:
|
||||
print(f'🔍 搜索用户: {args.name}')
|
||||
user_id = search_user(args.name, args.dry_run)
|
||||
if not user_id and not args.dry_run:
|
||||
sys.exit(1)
|
||||
|
||||
# 2. 拉取单聊消息(自动翻页)
|
||||
print(f'📥 拉取与 {user_id} 的聊天记录 (起始: {args.time})...')
|
||||
all_messages: List[Any] = []
|
||||
current_time = args.time
|
||||
page = 0
|
||||
max_pages = 50
|
||||
remaining = args.limit if args.limit > 0 else float('inf')
|
||||
|
||||
while page < max_pages and remaining > 0:
|
||||
cmd_args = [
|
||||
'chat', 'message', 'list',
|
||||
'--user', user_id or '<USER_ID>',
|
||||
'--time', current_time,
|
||||
'--format', 'json',
|
||||
]
|
||||
if args.no_forward:
|
||||
cmd_args.append('--forward=false')
|
||||
page_limit = min(int(remaining), 200) if args.limit > 0 else 0
|
||||
if page_limit > 0:
|
||||
cmd_args.extend(['--limit', str(page_limit)])
|
||||
data = run_dws(cmd_args, dry_run=args.dry_run)
|
||||
|
||||
if args.dry_run:
|
||||
print('[dry-run] 翻页循环: hasMore → 继续用边界 createTime 作为 --time')
|
||||
return
|
||||
|
||||
if not data:
|
||||
break
|
||||
|
||||
# 兼容两种结构: 顶层 messages / {result: {messages, hasMore}}
|
||||
if isinstance(data, list):
|
||||
page_msgs = data
|
||||
has_more = False
|
||||
else:
|
||||
container = data
|
||||
inner = data.get('result')
|
||||
if isinstance(inner, dict):
|
||||
container = inner
|
||||
page_msgs = container.get('messages')
|
||||
if page_msgs is None and isinstance(inner, list):
|
||||
page_msgs = inner
|
||||
if not isinstance(page_msgs, list):
|
||||
page_msgs = []
|
||||
has_more = bool(container.get('hasMore', False))
|
||||
|
||||
if not page_msgs:
|
||||
break
|
||||
|
||||
all_messages.extend(page_msgs)
|
||||
remaining -= len(page_msgs)
|
||||
page += 1
|
||||
|
||||
if not has_more:
|
||||
break
|
||||
|
||||
last_msg = page_msgs[-1]
|
||||
boundary_time = (last_msg.get('createTime')
|
||||
or last_msg.get('createAt')
|
||||
or last_msg.get('time', ''))
|
||||
if not boundary_time or boundary_time == current_time:
|
||||
break
|
||||
current_time = boundary_time
|
||||
print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
|
||||
|
||||
if not all_messages:
|
||||
print('未拉取到消息')
|
||||
return
|
||||
|
||||
# 3. 输出结果
|
||||
if args.output:
|
||||
with open(args.output, 'w', encoding='utf-8') as f:
|
||||
json.dump(all_messages, f, ensure_ascii=False, indent=2)
|
||||
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
|
||||
else:
|
||||
for m in all_messages:
|
||||
# 实际字段: sender/createTime/content (兼容旧字段名)
|
||||
sender = (m.get('sender') or m.get('senderNick')
|
||||
or m.get('senderOpenDingTalkId', '未知'))
|
||||
text = m.get('content') or m.get('text', '')
|
||||
time_str = (m.get('createTime') or m.get('createAt')
|
||||
or m.get('time', ''))
|
||||
print(f" [{time_str}] {sender}: {text[:80]}")
|
||||
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -11,11 +11,19 @@ metadata:
|
||||
|
||||
# 钉钉群聊 / 消息 Skill
|
||||
|
||||
## 前置条件 — 执行操作前必读
|
||||
<!-- DWS_RUNTIME_CONTRACT_START -->
|
||||
## 最小 DWS 执行契约
|
||||
|
||||
> **CRITICAL — 执行任何 `dws` 操作前,MUST 先用 Read 工具完整读取 [`dws-shared`](../dws-shared/SKILL.md)。**该轻量文件包含全局执行契约、安全底线及 shared references 的按需加载导航;不要预加载其全部 references。
|
||||
|
||||
> 命令参考:[chat.md](references/chat.md);表情:[chat-emoji-list.md](references/chat-emoji-list.md);剧本:[01-messaging.md](references/01-messaging.md)。
|
||||
- 只通过 `dws` CLI 操作钉钉;结构化读取使用 `--format json`,按真实返回判断结果。
|
||||
- 已知命令直接执行。只有 leaf 参数或安全语义不确定时读取精确 Schema,只有 Cobra flag 不确定时读取精确 leaf Help;不要加载产品级 Catalog 代替选路。
|
||||
- 不猜命令、flag、字段、ID、账号或时间。后续 ID 必须来自真实返回;零命中、多候选或类型不明时停止并消歧。
|
||||
- 解析目标、读取上下文和最终执行必须使用同一 profile;不得跨组织复用 userId、openDingTalkId 或 openConversationId。多账号组织只使用明确的 `isOrgCurrent=true` 默认账号;没有默认账号时要求用户指定,禁止选择第一项、最近登录或最近使用账号。
|
||||
- 不输出或记录 token、refresh token、appSecret、webhook token 等凭据;宿主已注入认证时不要索要凭据。
|
||||
- 写操作必须符合用户明确意图。是否需要确认以最终 Runtime gate 和 Schema 为准;需要确认时先说明对象、动作与影响,再追加 `--yes`。
|
||||
- 写后按任务结果契约验证;不能仅凭退出码宣称成功。部分结果、未知投递状态和失败项必须如实保留。
|
||||
- 时间戳面向用户展示时转换为带时区的可读时间;默认使用当前会话时区,必要时同时保留原值。
|
||||
- 遇到认证、权限、profile、confirmation 或未知错误时,只加载 `dws-shared` 中对应 reference;不要连续猜测替代命令。
|
||||
<!-- DWS_RUNTIME_CONTRACT_END -->
|
||||
|
||||
<!-- VISIBLE_SHORTCUTS_START -->
|
||||
## Shortcut 发现(按需)
|
||||
@@ -25,112 +33,84 @@ metadata:
|
||||
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service chat --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
|
||||
<!-- VISIBLE_SHORTCUTS_END -->
|
||||
|
||||
## IM Shortcut 优先路由
|
||||
## Golden Route
|
||||
|
||||
- 统一发送优先用 `chat +messages-send`:通过 `--as user|bot|webhook` 选择身份,并只传该身份真实支持的目标、内容和凭据。仅在需要原子命令的特定返回结构或兼容参数时,才降级到 `chat message send` / `send-by-bot` / `send-by-webhook`。
|
||||
- 消息查询按意图选择:指定会话用 `+chat-messages`,跨维度过滤/全量翻页用 `+search-msg`,@我用 `+at-me`,已知消息 ID 批量富化用 `+messages-mget`,已知 thread/topic ID 读取回复用 `+thread-replies`。
|
||||
- 查询结果中的 `resourceRefs` 是可继续执行的资源上下文。引用、回复或合并转发中的子消息必须使用该子消息返回的 `messageId`;子消息缺会话 ID 时才继承父消息的 `openConversationId`。
|
||||
- 上述五个查询 Shortcut 与 `+messages-resource-download` 都沿用安全本地下载的 `read/not_required` 契约,不应添加 `--yes` 或触发交互确认。下载只允许工作目录内相对路径、默认不覆盖并原子落盘;需要覆盖时必须由用户显式传 `--overwrite`。
|
||||
- `+messages-send` 会按身份规范化 @ 占位符:user 使用 `<@id>` / `<@all>`,bot/webhook 使用 `@id` / `@手机号` / `@all`;只需声明 `--at-*` / `--at-all`,缺失占位符会自动补齐。
|
||||
- `+messages-send --as user --user <userId>` 会通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId,所有内容类型与 `--dry-run` 都使用同一只读解析链路;已有 openDingTalkId 时直接传 `--open-dingtalk-id`。
|
||||
- 流式卡片优先用 `+messages-send-card`:群聊传 `--group`,单聊 userId 传 `--receiver` 并由 CLI 通过通讯录关键词搜索做 userId 精确匹配,已有 openDingTalkId 则必须显式传 `--receiver-open-dingtalk-id`;三者严格三选一,禁止根据首字母猜身份类型。
|
||||
按用户任务选择最小充分入口。公开层按意图分流;Resolver、发送执行、消息投影和错误契约在 Runtime 内复用,不把所有能力塞进一个万能命令。
|
||||
|
||||
## 意图表
|
||||
| 用户意图 | 唯一推荐入口 | 关键边界 |
|
||||
|---|---|---|
|
||||
| <!-- dws-intent: chat.send.dm -->按姓名发简单文本或 Markdown | `dws chat +dm --to <姓名> --text <内容>` | CLI 解析唯一用户;多候选时停止,不先手工查 ID |
|
||||
| <!-- dws-intent: chat.send.group -->按群名或 ID 发简单文本或 Markdown | `dws chat +send-to-group --group <群名或ID> --text <内容>` | 稳定 ID 直接使用;群名多候选时停止 |
|
||||
| <!-- dws-intent: chat.send.advanced -->文件、Bot、Webhook、复杂 @ 或高级发送 | `dws chat +messages-send` | Bot 多群用 `--groups/--groups-file` 并检查逐项 ledger |
|
||||
| <!-- dws-intent: chat.read.conversation -->读取指定群聊或单聊 | `dws chat +chat-messages` | 全量加 `--page-all`,检查完整性 ledger |
|
||||
| <!-- dws-intent: chat.search.cross-conversation -->跨会话多维搜索 | `dws chat +search-msg` | 内容用 `--query`;检查 `complete` |
|
||||
| 查看指定群成员(用户/机器人) | `dws chat +chat-members-list --group <群名或ID>` | 唯一解析并全量读取 |
|
||||
| 获取群邀请链接 | `dws chat +chat-invite-url --group <群名或ID>` | 多候选时停止 |
|
||||
| 查看群机器人 | `dws chat +chat-bots --group <群名或ID>` | 返回稳定 `bots[]` |
|
||||
| 修改群名称 | `dws chat group rename --id <openConversationId> --name <新名称>` | 只知群名时先用 `+chat-search --query <群名>` 唯一解析 ID;不猜 `+chat-rename` |
|
||||
| 查看指定群内 @我的消息 | `dws chat +at-me --group <群名>` | 空结果仍返回数组 |
|
||||
| 查看全部会话 | `dws chat +conversation-list --page-all` | 检查 `complete` / `failures` |
|
||||
| 读取并下载消息资源 | 查询命令加 `--download-resources` | 不另起手工下载循环;下载失败项保留在结果中 |
|
||||
| <!-- dws-intent: chat.conversation.list-top -->查看置顶会话 | `dws chat +conversation-list-top` | 会话 Top 与消息 Pin、消息 Top、Favorite 不同 |
|
||||
| 监听 IM 事件 | 切换 `dingtalk-event` | 由事件 Skill 选择确定的 EventKey 和生命周期 |
|
||||
|
||||
| 用户说 | 命令 |
|
||||
|--------|------|
|
||||
| "发消息给张三" | `dws chat +messages-send --as user --open-dingtalk-id <id> --text "<内容>"` |
|
||||
| "发到XX群" | `dws chat +chat-search --query "<群名>"` → `dws chat +messages-send --as user --chat-id <openConversationId> --text "<内容>"` |
|
||||
| "建群" / "拉人进群" | `dws chat group create` / `dws chat group members add` |
|
||||
| "改群名" / "踢人" | `dws chat group rename` / `dws chat group members remove --yes`(踢人不可逆,先确认目标) |
|
||||
| "@我消息" | `dws chat +at-me` |
|
||||
| "查群聊记录" | `dws chat +chat-messages --group <openConversationId>` |
|
||||
| "收藏/取消收藏这条消息" | `dws chat +flag-create` / `dws chat +flag-cancel` |
|
||||
| "查看我收藏的消息" | `dws chat +flag-list` |
|
||||
| "用机器人发消息" | `dws chat +messages-send --as bot --robot-code <code> --chat-id <id> --text "<内容>"` |
|
||||
| "Webhook 推一条" | `dws chat +messages-send --as webhook --webhook-token <token> --text "<内容>"` |
|
||||
| "发送流式卡片" | `dws chat +messages-send-card --group <openConversationId> --content "<内容>"`;单聊 userId 改用 `--receiver`,openDingTalkId 改用 `--receiver-open-dingtalk-id` |
|
||||
| "撤回消息" | `dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId>` |
|
||||
| "标记未读 / 清除红点 / 全部已读" | `dws chat mark-unread` / `dws chat clear-red-point` / `dws chat clear-all-red-point` |
|
||||
| "置顶某条消息 / 取消消息置顶" | `dws chat message set-top-msg` / `dws chat message unset-top-msg` |
|
||||
| "我加入的所有群 / 全部群列表" | `dws chat group list-all` |
|
||||
以下次级入口在意图明确时直接使用,不需要先加载完整 Catalog:
|
||||
|
||||
## 标准 SOP(必遵流程)
|
||||
| 用户意图 | 入口 |
|
||||
|---|---|
|
||||
| 已知消息 ID 批量读取详情 | `dws chat +messages-mget` |
|
||||
| 已知资源引用单独下载 | `dws chat +messages-resource-download` |
|
||||
| <!-- dws-intent: chat.reply.quote -->引用回复 | `dws chat +messages-reply`;成功结果保留新消息/会话/投递与原消息来源上下文 |
|
||||
| 撤回当前用户消息 | `dws chat +messages-recall --msg-id <openMessageId>`;可省略会话 ID,由 CLI 只读补齐;兼容单值 `--message-ids` |
|
||||
| 已知 thread/topic ID 读取回复 | `dws chat +thread-replies` |
|
||||
| <!-- dws-intent: chat.create.group -->按成员 ID 或姓名创建群聊 | `dws chat +chat-create`;成员/群主均可自然解析,任一歧义都会在创建前整体停止 |
|
||||
| 跨全部会话查看 @我的消息 | `dws chat +at-me` |
|
||||
|
||||
> 命中以下意图**必须**按对应 SOP 顺序执行;**禁止**跳步、替换命令、编造 ID。每条命令必须带 `--format json`,执行后必须按"解析"步取真实字段(`openDingTalkId` / `openConversationId` / `openMessageId`)。
|
||||
### 发送入口边界
|
||||
|
||||
### SOP-1 发消息(send-message)
|
||||
- `+dm`:姓名目标的简单文本/Markdown,参数空间最小。
|
||||
- `+send-to-group`:群名或稳定 ID 目标的简单文本/Markdown,避免暴露无关身份矩阵。
|
||||
- `+messages-send`:文件、Bot、Webhook、复杂 @ 或幂等控制。user 已知 ID 可直接传,也可用 `--user-query` / `--chat-query` 运行同一只读解析链;Bot 多群使用 `--groups/--groups-file`,返回 `im.batch-write.v1`;bot/webhook 只使用下层真实支持的文本/Markdown 能力。
|
||||
- 文件直接传 `+messages-send --file <相对路径>`;不要先独立上传并提取 mediaId。
|
||||
- Webhook 使用 `+messages-send --as webhook --webhook-token <token>`;不要退回原子 Webhook 命令。
|
||||
- 流式卡片不是普通消息内容,使用 `+messages-send-card`;当前只支持 streaming text create/update,不支持 Card JSON 或 callback。
|
||||
|
||||
**触发**:发消息/单聊/通知某人/发到群里。
|
||||
## 关键结果语义
|
||||
|
||||
1. **解析收件人(必须)**:人名 → 先 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 取 `openDingTalkId`(优先)或 `userId`;群名 → 先 `dws chat search --query "<群名>" --format json` 取 `openConversationId`。
|
||||
2. **执行(必须)**:单聊 `dws chat message send --open-dingtalk-id <openDingTalkId> --text "<内容>" --format json`(只有拿不到 `openDingTalkId` 时才用 `--user <userId>`);群聊 `dws chat message send --group <openConversationId> --text "<内容>" --format json`。
|
||||
3. **验证(必须)**:发送接口成功时可能只返回 `result.openTaskId`,它可用于确认提交成功,但**不是撤回所需消息 ID**。需要撤回时必须按 SOP-7 用带 `--time` 的 `message list` 回查,取得真实 `openMessageId`;返回非 `success` 必须如实报错,不要谎报已发。
|
||||
- `openTaskId` 是发送任务 ID,不是回复或撤回所需的消息 ID;消息 ID 必须来自真实查询结果。
|
||||
- 消息查询默认保留稳定 ID、会话/thread、发送者、文本、时间、reaction、引用、转发和 `resourceRefs`;`--no-reactions` 可关闭 reaction。
|
||||
- 查询结果必须检查 `complete`、`hasMore`、`failures` 和资源下载 ledger;partial result 不得表述为完整成功。
|
||||
- 子消息使用自己的 `messageId`;仅缺会话 ID 时继承父消息的 `conversationId`。
|
||||
- 下载只允许工作目录内安全相对路径,默认不覆盖并原子落盘;覆盖必须由用户显式传 `--overwrite`。读取和下载不需要 `--yes`。
|
||||
- Favorite、消息 Pin、消息 Top、会话 Top 是不同对象层级,不能互换。
|
||||
|
||||
**禁止**:把人名/群名直接当 ID 传入、跳过 `aisearch person`/`chat search` 解析、跳过 `--format json`、未发送成功就答复"已发送"。
|
||||
## 按需加载
|
||||
|
||||
### SOP-2 建群(create-group)
|
||||
只在任务命中时读取一个精确 reference:
|
||||
|
||||
**触发**:建群/拉人进群/新建讨论组。
|
||||
| 场景 | Reference |
|
||||
|---|---|
|
||||
| 复杂发送、跨会话转发、共同群或组合流程 | [01-messaging.md](references/01-messaging.md) |
|
||||
| 引用、转发、卡片、reaction、Pin/Top/Favorite | [chat-message.md](references/chat/chat-message.md) |
|
||||
| 建群、成员、管理员、群公告、群设置 | [chat-group.md](references/chat/chat-group.md) |
|
||||
| Bot 搜索、入群、群发和撤回 | [chat-bot.md](references/chat/chat-bot.md) |
|
||||
| 会话置顶、分类、红点、免打扰和隐藏 | [chat-conversation.md](references/chat/chat-conversation.md) |
|
||||
| 低频意图之间仍需消歧 | [intent-guide.md](references/intent-guide.md) |
|
||||
| 表情名称与 ID | [chat-emoji-list.md](references/chat-emoji-list.md) |
|
||||
| 稳定结果、身份矩阵与能力边界 | [contracts.md](references/contracts.md) |
|
||||
| 流式卡片创建 | [card/create.md](references/card/create.md) |
|
||||
| 流式卡片更新 | [card/update.md](references/card/update.md) |
|
||||
| 卡片 callback 是否可用 | [card/callback.md](references/card/callback.md) |
|
||||
| 卡片公开 Schema 边界 | [card/schema.md](references/card/schema.md) |
|
||||
| 只有上述 reference 仍无法定位的原子能力 | [chat.md](references/chat.md) 的对应章节 |
|
||||
|
||||
1. **解析成员(必须)**:对每个成员 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 取 `userId`,多人英文逗号拼接。
|
||||
2. **执行(必须)**:`dws chat group create --name "<群名>" --users <userId1,userId2,...> --format json`;外部群加 `--type EXTERNAL`,话题圈加 `--thread`。
|
||||
3. **验证(必须)**:从返回取 `openConversationId`,可用 `dws chat search --query "<群名>" --format json` 复核。
|
||||
不要预加载这些 reference。完整 Shortcut Catalog 只在根路由和精确 reference 都无法定位低频能力时使用。
|
||||
|
||||
**禁止**:跳过成员 userId 解析直接传姓名、编造 `openConversationId`。
|
||||
## 错误最短路径
|
||||
|
||||
### SOP-3 Webhook 推送(send-by-webhook)
|
||||
|
||||
**触发**:用机器人群 webhook 推一条消息。
|
||||
|
||||
1. **执行(必须)**:`dws chat message send-by-webhook --token <webhookToken> --title "<标题>" --text "<内容>" --format json`。
|
||||
2. **@ 人(必须)**:需要 @ 时,`--text` 中**必须**先包含对应 `@userId` / `@手机号` / `@10`,再配合 `--at-users` / `--at-mobiles` / `--at-all`;否则 @ 不生效。
|
||||
|
||||
**禁止**:只传 `--at-users` 而 `--text` 里不含 `@<标识>`。
|
||||
|
||||
### SOP-4 共同群查询(search-common-group)
|
||||
|
||||
**触发**:"我和 XX 的共同群"。
|
||||
|
||||
1. **取昵称(必须)**:先 `dws contact user get-self --format json` 取自己昵称;对方昵称从历史/上下文取,拿不到必须先问用户。
|
||||
2. **执行(必须)**:`dws chat search-common --nicks "<昵称1>,<昵称2>" --limit 20 --cursor 0 --format json`;`hasMore=true` 时**必须**用 `nextCursor` 翻页,不要停在第一页。
|
||||
|
||||
**禁止**:跳过昵称解析、忽略 `hasMore` 不翻页。
|
||||
|
||||
### SOP-5 红点 / 未读管理(manage-red-point)
|
||||
|
||||
**触发**:标记未读/清除红点/全部已读。
|
||||
|
||||
1. **执行(必须)**:标记某会话未读 `dws chat mark-unread --conversation-id <openConversationId> --format json`;清除某会话红点 `dws chat clear-red-point --conversation-id <openConversationId> --format json`;全部已读 `dws chat clear-all-red-point --format json`。
|
||||
2. **取会话 ID(必须)**:`openConversationId` 拿不准时先 `dws chat group list-all --format json` 或 `dws chat search --query "<群名>" --format json`,**禁止**编造。
|
||||
|
||||
**禁止**:未确认会话就批量"全部已读"(破坏性,必须先与用户确认)。
|
||||
|
||||
### SOP-6 特别关注消息(focus-messages)
|
||||
|
||||
**触发**:"特别关注的人最近发了什么/聊了什么"。
|
||||
|
||||
1. **执行(必须)**:`dws chat message list-focused --limit 50 --format json`,直接基于返回答复。
|
||||
2. **边界(必须)**:只有用户终点是"我关注了谁"这种**人员列表**时,才切 `dingtalk-contact` 关系查询。
|
||||
|
||||
**禁止**:用普通 `message list` 冒充 focused、把人员列表需求硬塞进 chat。
|
||||
|
||||
### SOP-7 拉取 / 撤回消息(list-or-recall-message)
|
||||
|
||||
**触发**:查某个群或单聊的聊天记录、撤回某条消息。
|
||||
|
||||
1. **定位会话(必须)**:群名先 `dws chat search --query "<群名>" --format json` 取 `openConversationId`;单聊对象先解析真实 `userId` 或 `openDingTalkId`。
|
||||
2. **拉取消息(必须)**:`dws chat message list --group <openConversationId> --time "<yyyy-MM-dd HH:mm:ss>" --direction older --format json`;单聊将 `--group` 换成 `--user` 或 `--open-dingtalk-id`。`--time` 是必填参数,必须来自用户时间范围或明确收敛后的边界。
|
||||
3. **撤回(必须)**:仅在用户明确要求撤回时,从消息列表 `result.messages[].openMessageId` 取真实 ID,执行 `dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId> --format json`。
|
||||
|
||||
**禁止**:省略 `--time`、用发送返回的 `clientMsgId` 代替 `openMessageId`、只传 `--client-msg-id`、编造会话或消息 ID。
|
||||
|
||||
## 跨产品协作
|
||||
|
||||
- 收件人是人名 → 先用 `dingtalk-contact` 或 `dingtalk-aisearch` 拿 `openDingTalkId` / `userId`
|
||||
- 要发图片/文件 → 先 `dt_media_upload` 上传 → `python scripts/extract_media_id.py "<URL>"` 提取 mediaId → 再用 `--media-id`
|
||||
- 紧急升级(应用内/短信/电话)→ 切到 `dingtalk-misc`(`references/ding.md`)
|
||||
- 发邮件 → 切到 `dingtalk-mail`
|
||||
## 局部意图与短流程
|
||||
|
||||
- [局部意图消歧](references/intent-guide.md);[短流程](references/lite-recipes.md)。
|
||||
1. resolution 返回零命中或多候选:停止写操作,展示候选并让用户消歧;禁止默认第一项。
|
||||
2. `unknown command` / `unknown flag`:读取精确 leaf Help,修正后最多重试一次。
|
||||
3. 参数约束或 confirmation 不清楚:读取精确 leaf Schema,以 Runtime gate 为准。
|
||||
4. 认证、权限、profile 或 confirmation 错误:读取 `dws-shared` 的对应 reference;正常 IM 不读取完整 shared Skill。
|
||||
5. `backend_dependency_unavailable`:保持原参数,对只读命令最多重试一次;不要改 flag、猜认证命令或切换同义原子命令,持续失败时保留 Trace ID。
|
||||
6. 其他错误:保留真实错误和已完成/失败项;不要连续尝试同义原子命令。
|
||||
|
||||
@@ -1,13 +1,91 @@
|
||||
# 消息沟通
|
||||
# 消息任务级流程
|
||||
|
||||
> lite recipe(`send-message`、`create-group`、`send-by-webhook`、`search-common-group`、`focus-messages`)见 [lite-recipes.md](./lite-recipes.md)。
|
||||
只在单个 Golden Route 不能完成任务、需要跨步骤传递真实结果时读取本文件。简单姓名/群名文本发送、单会话读取和跨会话搜索直接按根 Skill 执行。
|
||||
|
||||
| Recipe | 行动指南(固定路线) |
|
||||
|--------|-------------------|
|
||||
| query-group-chat | **优先**:`python scripts/chat_export_messages.py --query "<群名>" --time "<yyyy-MM-dd HH:mm:ss>" [--no-forward] [--limit N] [--output messages.json]`(自动搜群+翻页+导出)<br>备选:1. `chat search --query "<群名>"` → 取 `openConversationId`<br>2. `chat message list --group <openConversationId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表<br>3. **翻页**:`hasMore=true` 时取本页最后 `createTime` 作为下次 `--time`,重复至 `hasMore=false`<br>4. `--forward=false` 拉给定时间**之前**的消息<br>5. 合并全部消息后总结 |
|
||||
| query-private-chat | **优先**:`python scripts/chat_history_with_user.py --name "<姓名>" --time "<yyyy-MM-dd HH:mm:ss>" [--no-forward] [--limit N] [--output messages.json]`(自动搜人+翻页+导出)<br>备选:1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`<br>2. `chat message list --user <userId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表<br>3. **翻页**:同 query-group-chat<br>4. 合并全部消息后总结 |
|
||||
| escalate-ding | 三级升级:<br>1. `ding message send --robot-code <robotCode> --type app --users <userId> --content "<内容>"`(必填项见 `dingtalk-misc` 的 `references/ding.md`)<br>2. `chat message send --group <openConversationId> --text "<内容>"` 群里提醒(可选 `--title` / `@` 见 [chat.md](./chat.md))<br>3. `todo task create --title "<标题>" --executors <userId> --priority 40` 建紧急待办<br>前置:`aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`;`chat search --query "<群名>"` → 取 `openConversationId` |
|
||||
| send-by-bot | **多群批量优先**:`python scripts/bot_broadcast.py --robot-code <robotCode> --chats <id1>,<id2> --title "<标题>" --text "<内容>"`<br>单群:1. `chat bot search` → 取 `robotCode`<br>2. `chat search --query "<群名>"` → 取 `openConversationId`<br>3. `chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title "<标题>" --text "<内容>"` |
|
||||
| forward-message | 1. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message list --group <openConversationId> --time "<起始时间>"` 拉源消息<br>2. `aisearch person --keyword "<姓名>" --dimension name` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取目标 `openConversationId`<br>3. `chat message send --open-dingtalk-id <openDingTalkId> --text "<内容>"`(推荐)或 `--group <openConversationId> --text "<内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user <userId>`(备选) |
|
||||
| search-common-group | `chat search-common --nicks "<昵称1>,<昵称2>" --limit 20 --cursor 0`(`--match-mode AND`=全在/`OR`=任一在,翻页:`hasMore=true` 时用 `nextCursor`)<br>用户说"我和XX的共同群" → nicks 包含"我"时,需先 `contact user get-self` 取自己昵称再拼接 |
|
||||
| focus-messages | **零参数一行命令**:`chat message list-focused --limit 50`(拉特别关注人发的消息聚合)<br>触发 query:`"我特别关注的人最近发了什么消息"`、`"关注的人最近聊了啥"`、`"星标联系人最近的动态"`<br>**强消歧**:query 含动词【发/说/聊/讲】或名词【消息/聊天/动态】 → **必须**走本命令,**不要**先去拉 `contact relation list-my-followings`;仅当用户终点是"人员列表"(如"我关注了谁")才走 `relation list-my-followings`(详见 `dingtalk-contact` 易混淆硬规则)<br>翻页:`hasMore=true` 时用 `nextCursor` 作为下次 `--cursor`<br>按人精控(可选):先 `contact relation list-my-followings` 取 `openDingTalkId`,再 `chat message list-by-sender --sender-open-dingtalk-id <openDingTalkId> --start <ISO> --end <ISO>` |
|
||||
## 选择路线
|
||||
|
||||
1. 先选择任务语义最窄的 Shortcut。
|
||||
2. 只有 Shortcut 暂不接受自然目标或目标类型时,才用一个只读 leaf/Shortcut 解析 ID。
|
||||
3. 解析全部完成并消歧后再写入;不要边解析边产生部分副作用。
|
||||
4. 后续步骤只使用真实返回字段,不从名称、URL 或上下文猜 ID。
|
||||
|
||||
## 群聊消息
|
||||
|
||||
<!-- dws-intent: chat.read.conversation -->读取指定群聊或单聊默认使用 `dws chat +chat-messages`;它负责目标唯一解析、稳定消息投影和完整性 ledger。
|
||||
|
||||
已知群 ID 时直接读取:
|
||||
|
||||
```bash
|
||||
dws chat +chat-messages --group <openConversationId> --format json
|
||||
```
|
||||
|
||||
要求全量或导出时直接使用 Runtime 能力:
|
||||
|
||||
```bash
|
||||
dws chat +chat-messages --group <openConversationId> \
|
||||
--page-all --page-limit 50 \
|
||||
--output ./exports/messages.json \
|
||||
--format json
|
||||
```
|
||||
|
||||
必须检查 `complete/hasMore/nextPage/stopReason/failures`;达到页数或结果上限不是来源完整。
|
||||
|
||||
只有群名时,读取历史直接用 `+chat-messages --group <群名>`,普通文本发送直接用 `+send-to-group`。其它尚不接受群名的高级动作才先用 `+chat-search --query <群名>`;只有唯一候选才把 `openConversationId` 传给下一步。查询结果需要资源时在读取命令上加 `--download-resources`,不要让 Agent 手工遍历资源引用。按姓名读取单聊时先解析唯一用户 ID,再传给 `+chat-messages --user`。
|
||||
|
||||
## 发送消息
|
||||
|
||||
- <!-- dws-intent: chat.send.dm -->姓名 + 简单文本:`dws chat +dm`。
|
||||
- <!-- dws-intent: chat.send.group -->群名 + 简单文本:`dws chat +send-to-group`。
|
||||
- <!-- dws-intent: chat.send.advanced -->已知 ID、文件、Bot、Webhook、复杂 @ 或幂等:`dws chat +messages-send`。
|
||||
- 姓名 + 文件/高级控制:`+messages-send --as user --user-query <姓名> --file <相对路径>`。
|
||||
- 群名 + 文件/高级控制:`+messages-send --as user --chat-query <群名> --file <相对路径>`。
|
||||
- Bot 多群文本/Markdown:`+messages-send --as bot --robot-code <code> --groups <cid1,cid2>`;
|
||||
Runtime 去重并返回 `im.batch-write.v1` 逐目标 ledger,最多 100 个稳定群 ID。
|
||||
|
||||
`--user-query` 和 `--chat-query` 会在 CLI 内运行真实只读解析;零命中或多候选时在上传或发送前停止。Bot/Webhook 不接受这两个自然目标参数。
|
||||
|
||||
文件直接交给 `+messages-send --file`。不要恢复“独立上传 → 提取 mediaId → 发送”的旧默认链路。
|
||||
|
||||
## 创建群聊
|
||||
|
||||
<!-- dws-intent: chat.create.group -->基础建群默认使用 `dws chat +chat-create`;它同时接受 `--users` 稳定 ID 和 `--member-query` 姓名/花名。群主默认当前用户,也可用 `--owner-open-dingtalk-id` 或 `--owner-query` 明确指定。自然身份解析、候选消歧、稳定 ID 去重和创建前预检都由 CLI 完成:
|
||||
|
||||
```text
|
||||
传入全部姓名
|
||||
→ 对零命中和多候选统一消歧
|
||||
→ 按稳定 ID 去重
|
||||
→ 全部成功后执行一次 +chat-create
|
||||
```
|
||||
|
||||
任一成员或群主未唯一解析时不会创建群;显式群主会加入初始成员且不再读取当前用户,省略群主时才以当前用户兜底。`--dry-run` 也走同一解析链。不要用群名预搜索伪装幂等,因为业务上允许同名群。
|
||||
|
||||
## 机器人消息
|
||||
|
||||
已知 `robotCode` 时使用 `+messages-send --as bot`;单群用 `--group`,多群用 `--groups` 或工作目录内安全的 `--groups-file`。未知机器人、机器人入群或撤回读取 [chat-bot.md](chat/chat-bot.md)。Bot 不继承 user 的文件/图片能力;只使用 leaf Schema 明确发布的文本/Markdown 能力。
|
||||
|
||||
## 引用与转发
|
||||
|
||||
- <!-- dws-intent: chat.reply.quote -->引用回复:`dws chat +messages-reply`;优先继续使用结果中的 `messageId`、`conversationId`、`deliveryStatus` 和 `referencedMessage`,未知投递状态不得写成成功送达。
|
||||
- 单条转发:`+messages-forward`。
|
||||
- 合并转发:`+messages-combine-forward`。
|
||||
- 话题转发:`+messages-forward-topic`。
|
||||
|
||||
先用 `+chat-messages`、`+search-msg` 或 `+messages-mget` 取得真实 `messageId` 和 conversation/thread 上下文。引用或合并消息中的子消息优先使用自己的 `messageId`,不要拿父消息 ID 代替。
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 上一步 | 真实返回 | 下一步用途 |
|
||||
|---|---|---|
|
||||
| `+chat-search` | `openConversationId` | 高级发送、读取、群管理 |
|
||||
| `dingtalk-contact` 唯一用户解析 | `userId` / `openDingTalkId` | 单聊、建群、@、按发送者搜索 |
|
||||
| `+messages-send` | `openTaskId` / 投递结果 | 查询投递状态;不是回复/撤回消息 ID |
|
||||
| `+chat-messages` / `+search-msg` / `+messages-mget` | `messageId`、conversation/thread、`resourceRefs` | 回复、转发、撤回、资源下载 |
|
||||
| `+chat-create` | `openConversationId` | 新群后续消息与群管理 |
|
||||
| 分页查询 | `hasMore` / `nextCursor` / `complete` | 继续翻页和完整性判断 |
|
||||
|
||||
## 完成判断
|
||||
|
||||
- 写操作检查任务级结果或可查询状态,不只看退出码。
|
||||
- 读取检查 `complete`、`hasMore` 和 `failures`。
|
||||
- 下载检查每项 ledger;单项失败不抹掉已取得消息。
|
||||
- 投递状态未知时报告 unknown 并保留幂等键,不自动换目标重发。
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# 卡片回调边界
|
||||
|
||||
当前 DWS lower interface 没有卡片按钮/action callback 的订阅、验签或回复能力,
|
||||
`callback_supported=false`。因此:
|
||||
|
||||
- 不生成 callback URL、签名密钥或虚构的监听命令;
|
||||
- 不把 `dws event consume` 当作卡片 callback 的替代;
|
||||
- 用户必须使用按钮交互时,停止并说明当前不支持,等待平台接口和 Runtime 正式发布。
|
||||
|
||||
卡片的 create/update 能力不代表 callback 可用。
|
||||
@@ -0,0 +1,15 @@
|
||||
# 创建流式卡片
|
||||
|
||||
使用 `dws chat +messages-send-card`。群目标传 `--group`;单聊 userId 传 `--receiver`,
|
||||
Runtime 会唯一解析为 openDingTalkId;已有 openDingTalkId 时传
|
||||
`--receiver-open-dingtalk-id`。三种目标严格三选一。
|
||||
|
||||
- 只传目标:创建卡片并从真实结果取得 `bizId`,供后续更新。
|
||||
- 同时传 `--content`:Runtime 串行执行 create → 从返回提取 `bizId` → update;默认
|
||||
`--flow-status 3`。
|
||||
- `--dry-run` 仍执行只读 userId 解析,只输出两步计划,不执行写入。
|
||||
|
||||
创建成功但自动更新失败时,错误会保留真实 `bizId`。不要重复创建;使用该 `bizId` 继续
|
||||
`+messages-update-card` 或人工处理。
|
||||
|
||||
当前内容仅为 streaming text,不接受 Lark Card JSON、组件树或按钮 callback。
|
||||
@@ -0,0 +1,14 @@
|
||||
# 流式卡片 Schema
|
||||
|
||||
DWS 当前公开的是 `im.streaming-card.v1` 工作流契约,不是任意组件 Schema:
|
||||
|
||||
- target:group、direct user、direct openDingTalkId;
|
||||
- content:streaming text;
|
||||
- lifecycle:create 可选串联 update,后续按 `bizId` update;
|
||||
- flowStatus:1–5;
|
||||
- callback:不支持。
|
||||
|
||||
参数、required 和 confirmation 读取
|
||||
`dws schema --cli-path "chat +messages-send-card" -f json` 或
|
||||
`dws schema --cli-path "chat +messages-update-card" -f json`。不要把 Lark card JSON 字段翻译成
|
||||
未发布的 DWS flags。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user