Compare commits

...
Author SHA1 Message Date
玉澜andCursor 2ea5acc2a3 fix(helpers): align PR868 whiteboard coverage with main API
After merging #861, use prepareWhiteboardCard and --yes so coverage
tests compile and match fail-closed confirmation + soft pending verify.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:07:19 +08:00
玉澜andCursor 5e1bac51e5 Merge origin/main into fix/cli-missing-surfaces-from-eval
Keep main/#861 fail-closed whiteboard; retain #868 missing surfaces.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:02:27 +08:00
玉澜andCursor 65ce71b8d4 fix(helpers): avoid race on markdown diff compute seam
Capture runMarkdownUnifiedDiff/diffJSONMarshalIndent before spawning the
compute goroutine so testseam restores cannot race a late timeout path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 13:28:43 +08:00
玉澜andCursor 6bfcac4d54 test(helpers): use testseam for PR868 coverage seam swaps
Policy bans manual prev/t.Cleanup restores for package-var injection seams.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 13:08:24 +08:00
玉澜andCursor f819566c63 fix(schema): treat drive download --version as mapping exclusion
Keep the add-only Wukong compat flag, but publish it as a CLI-local
polymorphic dispatch without a download_file property binding.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 12:58:47 +08:00
玉澜andCursor 10d9aa3058 test(helpers): raise PR868 changed-line coverage to 100%
Exercise markdown diff, mail export/share, drive latest/depth, whiteboard,
and diff-engine edges via TestCrossPlatformCoverage*; add small injectable
seams only where defensive branches are otherwise unreachable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 12:50:00 +08:00
玉澜andCursor a0ca1fdb28 feat(helpers): add minutes missing surfaces and add-only flag aliases
Expose minutes hot-word delete, permission apply, and audio-memo list from
live MCP gaps; add hidden/cross-product flag aliases only (never remove),
with TestCrossPlatformCoverage coverage for the new surfaces.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 12:04:27 +08:00
玉澜andCursor 5a93f80daa fix(ci): align new-surface dry-run preview kinds with Schema
Whiteboard dry-run now stamps preview_kind=plan; mail export/share
drop [DRY-RUN] tags so plan evidence matches declared DryRunSpec.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 11:21:38 +08:00
玉澜andCursor 8260cf7f53 fix(ci): drop --yes from mail share-to-chat examples
Schema Manual examples forbid confirmation bypass via --yes; also stub
whiteboardSleep in product example coverage so race CI does not hang.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 11:05:54 +08:00
玉澜andCursor 58dfbc5b6e feat: sync missing open CLI surfaces from wukong cli_to_mcp gaps
Port calendar event instances, markdown diff, drive list --latest,
mail calendar/calendar-event/shared-with-me/export/share-to-chat, and
doc whiteboard insert so open edition matches documented Wukong test
command surfaces.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 10:39:56 +08:00
15 changed files with 3244 additions and 63 deletions
@@ -448,6 +448,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"drive.download_file --output": "local output path",
"drive.download_file --parallel": "local multipart download control; never sent to download_file",
"drive.download_file --part-size": "local multipart download control; never sent to download_file",
"drive.download_file --version": "Polymorphic dispatch: --version switches the MCP tool call from download_file to download_file_version; not a download_file interface property",
"drive.download_file_version --no-resume": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI-local multipart download control and does not publish a direct interface property.",
"drive.download_file_version --node": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.download_file_version --output": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --output is a CLI wrapper input and does not publish a direct interface property.",
@@ -655,7 +656,6 @@ var reviewedSchemaParameterBindingRemovals = map[string]schemaParameterBindingRe
"contact.get_dept_info_by_dept_id --id": {Reason: "The public flag was renamed from --id to the unambiguous --dept spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"contact.get_dept_members_by_deptId --ids": {Reason: "The public flag was renamed from --ids to the unambiguous --depts spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"contact.get_sub_depts_by_dept_id --id": {Reason: "The public flag was renamed from --id to the unambiguous --dept spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"drive.download_file --version": {Reason: "Polymorphic dispatch: --version switches the MCP tool call from download_file to download_file_version; the version property belongs to download_file_version metadata, not download_file.", Reviewed: true},
"minutes.query_user_tag_list --limit": {Reason: "The current helper and pinned interface have no pagination input.", Reviewed: true},
"oa.list_pending_approvals --size": {Reason: "The public pagination flag was normalized from --size to --limit; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"oa.list_user_visible_process --size": {Reason: "The public pagination flag was normalized from --size to --limit; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
+179 -2
View File
@@ -230,7 +230,7 @@ func newCalendarCommand() *cobra.Command {
Long: `管理钉钉日历:日程、参会人、会议室、闲忙、附件、日历本、访问权限。调用前必须先使用 --help 查看参数结构。
命令结构:
dws calendar event [list|get|create|update|delete|suggest|respond] 日程管理
dws calendar event [list|get|create|update|delete|suggest|respond|instances] 日程管理
dws calendar attendee [list|add|delete] 参会人管理
dws calendar room [search|add|delete|list-groups] 会议室管理
dws calendar busy search 闲忙查询 (可查人、查会议室)
@@ -2082,7 +2082,184 @@ func newCalendarCommand() *cobra.Command {
eventSuggestCmd.Flags().String("members", "", "")
_ = eventSuggestCmd.Flags().MarkHidden("members")
eventSuggestCmd.Flags().String("duration", "", "日程持续时间 (分钟,默认30)")
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd)
eventInstancesCmd := &cobra.Command{
Use: "instances",
Short: "查询循环日程的实例列表",
Long: `查询指定重复性日程(SeriesMaster)在指定时间范围内的所有实例。
**注意**:此接口只能查询重复性日程的实例;若传入的是普通非循环日程,将查不到任何实例信息。
必须传入 --id 指定重复性日程的 eventId(即 SeriesMaster 的 eventId,可通过 event list 获取)。
不传 --start/--end 时,默认查询今天(00:00:00 ~ 23:59:59)的实例。`,
Example: ` dws calendar event instances --id EVENT_ID
dws calendar event instances --id EVENT_ID --start "2026-03-10T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00"
dws calendar event instances --id EVENT_ID --limit 50
dws calendar event instances --id EVENT_ID --cursor "<nextCursor>"`,
RunE: func(cmd *cobra.Command, args []string) error {
eventID, err := mustFlagOrFallback(cmd, "id", "event", "event-id", "eventId")
if err != nil {
return err
}
toolArgs := map[string]any{"eventId": eventID}
var startTime, endTime int64
var now time.Time
if v := flagOrFallback(cmd, "start", "time-min", "min-time", "start-time", "startTime", "start_time", "start-date", "startDate"); v != "" {
startTime, err = parseISOTimeToMillis("start", v)
if err != nil {
return err
}
toolArgs["startTime"] = startTime
} else {
now = time.Now()
startTime = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).UnixMilli()
toolArgs["startTime"] = startTime
}
if v := flagOrFallback(cmd, "end", "time-max", "max-time", "end-time", "endTime", "end_time", "end-date", "endDate"); v != "" {
endTime, err = parseISOTimeToMillis("end", v)
if err != nil {
return err
}
toolArgs["endTime"] = endTime
} else {
if now.IsZero() {
now = time.Now()
}
endTime = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli()
toolArgs["endTime"] = endTime
}
if err := validateTimeRange(startTime, endTime); err != nil {
return err
}
if v := flagOrFallback(cmd, "calendar-id", "calendarId", "calendar"); v != "" {
toolArgs["calendarId"] = v
}
if v := flagOrFallback(cmd, "cursor", "next-cursor", "nextCursor", "page-token", "pageToken", "next-token"); v != "" {
toolArgs["cursor"] = v
}
if lim, _ := cmd.Flags().GetInt("limit"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("max-results"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("maxResults"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("page-size"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("size"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("count"); lim > 0 {
toolArgs["limit"] = lim
}
return callSortedCalendarEvents(cmd, "list_event_instances", toolArgs)
},
}
DeclareLeafMetadata(eventInstancesCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "calendar",
Name: "list_event_instances",
CanonicalPath: "calendar.list_event_instances",
CLIPath: "calendar event instances",
PrimaryCLIPath: "calendar event instances",
},
Description: "查询循环日程的实例列表",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "calendar", RPCName: "list_event_instances"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询循环日程在时间范围内展开的实例",
UseWhen: []string{"已知循环日程 eventId(SeriesMaster),需要列出某时间窗内的实例时"},
AvoidWhen: []string{
"普通非循环日程请用 dws calendar event get / list",
"未知 eventId 时先 dws calendar event list",
},
Examples: []string{
"dws calendar event instances --id <EVENT_ID>",
"dws calendar event instances --id <EVENT_ID> --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-31T23:59:59+08:00\"",
},
},
Parameters: []contract.ParamDecl{
{Name: "id", Property: "eventId", Required: boolPtr(true)},
{Name: "start", Property: "startTime"},
{Name: "end", Property: "endTime"},
{Name: "calendar-id", Property: "calendarId"},
{Name: "cursor", Property: "cursor"},
{Name: "limit", Property: "limit", InterfaceType: "integer"},
},
},
})
// InstancesEvent flags (aligned with event list aliases)
eventInstancesCmd.Flags().String("id", "", "日程 ID (必填)")
eventInstancesCmd.Flags().String("event", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("event")
eventInstancesCmd.Flags().String("event-id", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("event-id")
eventInstancesCmd.Flags().String("eventId", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("eventId")
eventInstancesCmd.Flags().String("start", "", "开始时间 ISO-8601 (例如 2026-03-10T00:00:00+08:00)")
eventInstancesCmd.Flags().String("time-min", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("time-min")
eventInstancesCmd.Flags().String("min-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("min-time")
eventInstancesCmd.Flags().String("start-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("start-time")
eventInstancesCmd.Flags().String("startTime", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("startTime")
eventInstancesCmd.Flags().String("start_time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("start_time")
eventInstancesCmd.Flags().String("start-date", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("start-date")
eventInstancesCmd.Flags().String("startDate", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("startDate")
eventInstancesCmd.Flags().String("end", "", "结束时间 ISO-8601 (例如 2026-03-31T23:59:59+08:00)")
eventInstancesCmd.Flags().String("time-max", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("time-max")
eventInstancesCmd.Flags().String("max-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("max-time")
eventInstancesCmd.Flags().String("end-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("end-time")
eventInstancesCmd.Flags().String("endTime", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("endTime")
eventInstancesCmd.Flags().String("end_time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("end_time")
eventInstancesCmd.Flags().String("end-date", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("end-date")
eventInstancesCmd.Flags().String("endDate", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("endDate")
eventInstancesCmd.Flags().String("calendar-id", "", "日历 ID (可选,默认 primary 主日历;指定其他日历本时填写,可通过 book list 获取)")
eventInstancesCmd.Flags().String("calendarId", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("calendarId")
eventInstancesCmd.Flags().String("calendar", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("calendar")
eventInstancesCmd.Flags().String("cursor", "", "分页游标 (首次查询无需传入,仅翻页时传入上一次返回的 nextCursor)")
eventInstancesCmd.Flags().String("next-cursor", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("next-cursor")
eventInstancesCmd.Flags().String("nextCursor", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("nextCursor")
eventInstancesCmd.Flags().String("page-token", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("page-token")
eventInstancesCmd.Flags().String("pageToken", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("pageToken")
eventInstancesCmd.Flags().String("next-token", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("next-token")
eventInstancesCmd.Flags().Int("limit", 0, "每页返回条数 (默认 100,最大 100)")
eventInstancesCmd.Flags().Int("max-results", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("max-results")
eventInstancesCmd.Flags().Int("maxResults", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("maxResults")
eventInstancesCmd.Flags().Int("page-size", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("page-size")
eventInstancesCmd.Flags().Int("size", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("size")
eventInstancesCmd.Flags().Int("count", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("count")
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd, eventInstancesCmd)
// participant
participantCmd.PersistentFlags().String("event", "", "日程 ID (必填)")
+282
View File
@@ -0,0 +1,282 @@
// Copyright 2022 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// This file is adapted from Go standard library's internal/diff package.
// The original source can be found at /usr/local/go/src/internal/diff/diff.go.
//
// Modifications:
// - Package changed from "diff" to "products"
// - Diff() renamed to UnifiedDiff() and accepts a contextLines parameter
// - const C replaced with the contextLines parameter
package helpers
import (
"bytes"
"fmt"
"sort"
"strings"
)
// A pair is a pair of values tracked for both the x and y side of a diff.
// It is typically a pair of line indexes.
type diffPair struct{ x, y int }
func nonNeg(v int) int {
if v < 0 {
return 0
}
return v
}
// UnifiedDiff returns an anchored diff of the two texts old and new
// in the "unified diff" format. If old and new are identical,
// UnifiedDiff returns a nil slice (no output).
//
// Unix diff implementations typically look for a diff with
// the smallest number of lines inserted and removed,
// which can in the worst case take time quadratic in the
// number of lines in the texts. As a result, many implementations
// either can be made to run for a long time or cut off the search
// after a predetermined amount of work.
//
// In contrast, this implementation looks for a diff with the
// smallest number of "unique" lines inserted and removed,
// where unique means a line that appears just once in both old and new.
// We call this an "anchored diff" because the unique lines anchor
// the chosen matching regions. An anchored diff is usually clearer
// than a standard diff, because the algorithm does not try to
// reuse unrelated blank lines or closing braces.
// The algorithm also guarantees to run in O(n log n) time
// instead of the standard O(n²) time.
//
// Some systems call this approach a "patience diff," named for
// the "patience sorting" algorithm, itself named for a solitaire card game.
// We avoid that name for two reasons. First, the name has been used
// for a few different variants of the algorithm, so it is imprecise.
// Second, the name is frequently interpreted as meaning that you have
// to wait longer (to be patient) for the diff, meaning that it is a slower algorithm,
// when in fact the algorithm is faster than the standard one.
func UnifiedDiff(oldName string, old []byte, newName string, new []byte, contextLines int) []byte {
if bytes.Equal(old, new) {
return nil
}
x := diffLines(old)
y := diffLines(new)
// Print diff header.
var out bytes.Buffer
fmt.Fprintf(&out, "diff %s %s\n", oldName, newName)
fmt.Fprintf(&out, "--- %s\n", oldName)
fmt.Fprintf(&out, "+++ %s\n", newName)
// Loop over matches to consider,
// expanding each match to include surrounding lines,
// and then printing diff chunks.
// To avoid setup/teardown cases outside the loop,
// tgs returns a leading {0,0} and trailing {len(x), len(y)} pair
// in the sequence of matches.
var (
done diffPair // printed up to x[:done.x] and y[:done.y]
chunk diffPair // start lines of current chunk
count diffPair // number of lines from each side in current chunk
ctext []string // lines for current chunk
)
for _, m := range diffTgs(x, y) {
if m.x < done.x {
// Already handled scanning forward from earlier match.
continue
}
// Expand matching lines as far as possible,
// establishing that x[start.x:end.x] == y[start.y:end.y].
// Note that on the first (or last) iteration we may (or definitely do)
// have an empty match: start.x==end.x and start.y==end.y.
start := m
for start.x > done.x && start.y > done.y && x[start.x-1] == y[start.y-1] {
start.x--
start.y--
}
end := m
for end.x < len(x) && end.y < len(y) && x[end.x] == y[end.y] {
end.x++
end.y++
}
// Emit the mismatched lines before start into this chunk.
// (No effect on first sentinel iteration, when start = {0,0}.)
for _, s := range x[done.x:start.x] {
ctext = append(ctext, "-"+s)
count.x++
}
for _, s := range y[done.y:start.y] {
ctext = append(ctext, "+"+s)
count.y++
}
// If we're not at EOF and have too few common lines,
// the chunk includes all the common lines and continues.
C := contextLines
if C < 0 {
// 防御性兼容:负值会破坏下方区间判定,按无上下文处理;0 是合法值,直接生效
C = 0
}
if (end.x < len(x) || end.y < len(y)) &&
(end.x-start.x < C || (len(ctext) > 0 && end.x-start.x < 2*C)) {
for _, s := range x[start.x:end.x] {
ctext = append(ctext, " "+s)
count.x++
count.y++
}
done = end
continue
}
// End chunk with common lines for context.
if len(ctext) > 0 {
n := end.x - start.x
if n > C {
n = C
}
for _, s := range x[start.x : start.x+n] {
ctext = append(ctext, " "+s)
count.x++
count.y++
}
done = diffPair{start.x + n, start.y + n}
// Format and emit chunk.
// Convert line numbers to 1-indexed.
// Special case: empty file shows up as 0,0 not 1,0.
if count.x > 0 {
chunk.x++
}
if count.y > 0 {
chunk.y++
}
fmt.Fprintf(&out, "@@ -%d,%d +%d,%d @@\n", chunk.x, count.x, chunk.y, count.y)
for _, s := range ctext {
out.WriteString(s)
}
count.x = 0
count.y = 0
ctext = ctext[:0]
}
// If we reached EOF, we're done.
if end.x >= len(x) && end.y >= len(y) {
break
}
// Otherwise start a new chunk.
// C is clamped to >= 0 above; nonNeg saturates end-C when the next
// hunk would start before line 0 (defensive; exercised via nonNeg tests).
chunk = diffPair{nonNeg(end.x - C), nonNeg(end.y - C)}
for _, s := range x[chunk.x:end.x] {
ctext = append(ctext, " "+s)
count.x++
count.y++
}
done = end
}
return out.Bytes()
}
// diffLines returns the lines in the file x, including newlines.
// If the file does not end in a newline, one is supplied
// along with a warning about the missing newline.
func diffLines(x []byte) []string {
l := strings.SplitAfter(string(x), "\n")
if l[len(l)-1] == "" {
l = l[:len(l)-1]
} else {
// Treat last line as having a message about the missing newline attached,
// using the same text as BSD/GNU diff (including the leading backslash).
l[len(l)-1] += "\n\\ No newline at end of file\n"
}
return l
}
// diffTgs returns the pairs of indexes of the longest common subsequence
// of unique lines in x and y, where a unique line is one that appears
// once in x and once in y.
//
// The longest common subsequence algorithm is as described in
// Thomas G. Szymanski, "A Special Case of the Maximal Common
// Subsequence Problem," Princeton TR #170 (January 1975),
// available at https://research.swtch.com/tgs170.pdf.
func diffTgs(x, y []string) []diffPair {
// Count the number of times each string appears in a and b.
// We only care about 0, 1, many, counted as 0, -1, -2
// for the x side and 0, -4, -8 for the y side.
// Using negative numbers now lets us distinguish positive line numbers later.
m := make(map[string]int)
for _, s := range x {
if c := m[s]; c > -2 {
m[s] = c - 1
}
}
for _, s := range y {
if c := m[s]; c > -8 {
m[s] = c - 4
}
}
// Now unique strings can be identified by m[s] = -1+-4.
//
// Gather the indexes of those strings in x and y, building:
// xi[i] = increasing indexes of unique strings in x.
// yi[i] = increasing indexes of unique strings in y.
// inv[i] = index j such that x[xi[i]] = y[yi[j]].
var xi, yi, inv []int
for i, s := range y {
if m[s] == -1+-4 {
m[s] = len(yi)
yi = append(yi, i)
}
}
for i, s := range x {
if j, ok := m[s]; ok && j >= 0 {
xi = append(xi, i)
inv = append(inv, j)
}
}
// Apply Algorithm A from Szymanski's paper.
// In those terms, A = J = inv and B = [0, n).
// We add sentinel pairs {0,0}, and {len(x),len(y)}
// to the returned sequence, to help the processing loop.
J := inv
n := len(xi)
T := make([]int, n)
L := make([]int, n)
for i := range T {
T[i] = n + 1
}
for i := 0; i < n; i++ {
k := sort.Search(n, func(k int) bool {
return T[k] >= J[i]
})
T[k] = J[i]
L[i] = k + 1
}
k := 0
for _, v := range L {
if k < v {
k = v
}
}
seq := make([]diffPair, 2+k)
seq[1+k] = diffPair{len(x), len(y)} // sentinel at end
lastj := n
for i := n - 1; i >= 0; i-- {
if L[i] == k && J[i] < lastj {
seq[k] = diffPair{xi[i], yi[J[i]]}
k--
}
}
seq[0] = diffPair{0, 0} // sentinel at start
return seq
}
+11 -4
View File
@@ -3657,11 +3657,13 @@ CLI 内部自动完成全部流程:
PROCESSING 处理中
SUCCESS 导出成功,返回 downloadUrl
FAILED 导出失败`,
Example: ` dws doc export get --job-id <JOB_ID>`,
Example: ` dws doc export get --job-id <JOB_ID>
dws doc export get --task-id <TASK_ID>`,
RunE: func(cmd *cobra.Command, _ []string) error {
jobID := mustGetFlag(cmd, "job-id")
if jobID == "" {
return fmt.Errorf("flag --job-id is required")
// Keep --job-id as the visible primary; --task-id is an add-only synonym.
jobID, err := mustFlagOrFallback(cmd, "job-id", "task-id")
if err != nil {
return err
}
if deps.Caller.DryRun() {
@@ -3728,9 +3730,14 @@ CLI 内部自动完成全部流程:
AvoidWhen: []string{"常规导出请直接 dws doc export(一体化提交+轮询+下载),不要先查 job"},
Examples: []string{"dws doc export get --job-id <JOB_ID> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "job-id", Property: "jobId"},
},
},
})
exportGetCmd.Flags().String("job-id", "", "导出任务 ID (必填)")
exportGetCmd.Flags().String("task-id", "", "--job-id 的别名")
_ = exportGetCmd.Flags().MarkHidden("task-id")
// --node 的隐藏别名(与 doc 下其他命令保持一致)
exportCmd.Flags().String("url", "", "--node 的别名")
+72 -7
View File
@@ -316,12 +316,24 @@ func newDriveCommand() *cobra.Command {
Example: ` dws drive list --limit 20
dws drive list --folder <dentryUuid> --order-by name --order asc
dws drive list --workspace <workspaceId>
dws drive list --workspace <workspaceId> --folder <folderId>`,
dws drive list --workspace <workspaceId> --folder <folderId>
dws drive list --latest 5
dws drive list --folder <dentryUuid> --latest 3 --pattern "*.docx"`,
RunE: func(cmd *cobra.Command, args []string) error {
pattern, _ := cmd.Flags().GetString("pattern")
depth, _ := cmd.Flags().GetInt("depth")
latest, _ := cmd.Flags().GetInt("latest")
if cmd.Flags().Changed("latest") {
if err := validateDriveListLatest(cmd, latest); err != nil {
return err
}
if cmd.Flags().Changed("versions") {
return &CLIError{Code: CodeInvalidParam, Message: "--latest 不能与 --versions 同时使用"}
}
}
// --versions 模式:列出文件历史版本(仅普通文件)
// 先于 --depth 校验执行:versions 模式合法使用 --limit,
// 不应被「--limit 与 --depth 不兼容」的误导性报错拦截。
@@ -363,7 +375,7 @@ func newDriveCommand() *cobra.Command {
if workspaceID != "" {
// depth>1 时 --pattern 放开(先递归后过滤);--order-by/--space-id/--thumbnail
// 知识库无对应参数,静默忽略。
if depth > 1 {
if depth > 1 || latest > 0 {
quiet, _ := cmd.Flags().GetBool("quiet")
baseArgs := map[string]any{"workspaceId": workspaceID}
rootFolder := docFolderFlag(cmd, "node", "file-id")
@@ -372,7 +384,7 @@ func newDriveCommand() *cobra.Command {
return err
}
}
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
}
if pattern != "" {
return &CLIError{
@@ -417,10 +429,27 @@ func newDriveCommand() *cobra.Command {
return err
}
}
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
}
// 默认路由:钉盘文件列表
if latest > 0 {
quiet, _ := cmd.Flags().GetBool("quiet")
baseArgs := map[string]any{}
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
baseArgs["spaceId"] = v
}
if v, _ := cmd.Flags().GetBool("thumbnail"); v {
baseArgs["withThumbnail"] = true
}
rootFolder := flagOrFallback(cmd, "folder", "parent-id")
if rootFolder != "" {
if err := validateDriveParentID(rootFolder); err != nil {
return err
}
}
return runDriveListLatest(cmd, baseArgs, rootFolder, latest, pattern, quiet)
}
maxResults, _ := cmd.Flags().GetInt("limit")
if !cmd.Flags().Changed("limit") {
if v, _ := cmd.Flags().GetInt("max"); v > 0 {
@@ -491,7 +520,7 @@ func newDriveCommand() *cobra.Command {
},
Examples: []string{
"dws drive list --limit 20 --format json",
"dws drive list --folder <dentryUuid> --limit 20 --format json",
"dws drive list --latest 5 --format json",
},
},
},
@@ -701,6 +730,8 @@ func newDriveCommand() *cobra.Command {
{Name: "part-size", Description: "分片下载的分片大小(如 8MB/16MB/1GB)"},
{Name: "parallel", Description: "分片下载并发数(1-8)"},
{Name: "no-resume", Description: "关闭断点续传"},
// Wukong compat alias: routes to download-version; not a download_file property.
{Name: "version", Description: "下载指定历史版本号(兼容别名,等价 download-version)"},
},
},
})
@@ -1052,7 +1083,8 @@ func newDriveCommand() *cobra.Command {
driveListCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (--versions 模式下必填)")
driveListCmd.Flags().String("pattern", "", "按名称通配过滤结果,如 \"*日报*\" (客户端过滤) (可选)")
driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥;与 --workspace 组合时走知识库递归 (可选)")
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (仅 --depth>1 时有效) (可选)")
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥 (可选)")
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (--depth>1 或 --latest 多页扫描时有效) (可选)")
driveInfoCmd.Flags().String("node", "", "节点 ID (dentryUuid) (必填)")
driveInfoCmd.Flags().String("space-id", "", "节点所属空间 ID (可选)")
@@ -1060,6 +1092,7 @@ func newDriveCommand() *cobra.Command {
driveDownloadCmd.Flags().String("node", "", "文件 ID (dentryUuid) (必填)")
driveDownloadCmd.Flags().String("space-id", "", "文件所属空间 ID (可选)")
driveDownloadCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
driveDownloadCmd.Flags().Int("version", 0, "下载指定历史版本号(兼容别名,等价 download-version)")
driveDownloadCmd.Flags().String("part-size", "16MB", "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)")
driveDownloadCmd.Flags().Int("parallel", 4, "分片下载并发数,范围 1-8 (可选)")
driveDownloadCmd.Flags().Bool("no-resume", false, "关闭断点续传 (可选)")
@@ -1074,6 +1107,14 @@ func newDriveCommand() *cobra.Command {
driveDownloadVersionCmd.Flags().String(alias, "", "")
_ = driveDownloadVersionCmd.Flags().MarkHidden(alias)
}
// Wukong compat: `drive download --version N` routes to download-version.
origDriveDownloadRunE := driveDownloadCmd.RunE
driveDownloadCmd.RunE = func(cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed("version") {
return driveDownloadVersionCmd.RunE(cmd, args)
}
return origDriveDownloadRunE(cmd, args)
}
driveMkdirCmd.Flags().String("name", "", "文件夹名称,最长 50 字符 (必填)")
driveMkdirCmd.Flags().String("space-id", "", "目标空间 ID,不传则使用「我的文件」 (可选)")
@@ -2019,6 +2060,8 @@ func newDriveCommand() *cobra.Command {
limit := 0
if cmd.Flags().Changed("limit") {
limit, _ = cmd.Flags().GetInt("limit")
} else if cmd.Flags().Changed("max-results") {
limit, _ = cmd.Flags().GetInt("max-results")
}
if limit > 0 {
toolArgs["maxResults"] = limit
@@ -2070,6 +2113,8 @@ func newDriveCommand() *cobra.Command {
})
drivePermListCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
drivePermListCmd.Flags().Int("max-results", 0, "")
_ = drivePermListCmd.Flags().MarkHidden("max-results")
drivePermListCmd.Flags().String("filter-role", "", "按角色过滤: OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
drivePermListCmd.Flags().String("workspace", "", "知识库 ID (选填)")
@@ -2733,7 +2778,7 @@ func newDriveCommand() *cobra.Command {
// ── cross-product hidden aliases ──
for _, cmd := range []*cobra.Command{
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd,
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd, driveDownloadVersionCmd,
driveMkdirCmd, driveUploadInfoCmd, driveCommitCmd, driveUploadCmd, driveDeleteCmd,
driveSearchCmd, driveCopyCmd, driveMoveCmd, driveRenameCmd, driveStatsCmd, driveShortcutCmd,
driveFolderCreateCmd,
@@ -3035,6 +3080,11 @@ func newDriveCommand() *cobra.Command {
},
})
driveCoverCmd.Flags().String("node", "", "节点 ID (dentryUuid) 或文档 URL (必填)")
for _, alias := range []string{"url", "id"} {
driveCoverCmd.Flags().String(alias, "", "--node 的别名")
_ = driveCoverCmd.Flags().MarkHidden(alias)
}
RegisterCrossProductAliases(driveCoverCmd)
// ── drive revert (回滚文件到指定历史版本) ──
driveRevertCmd := &cobra.Command{
@@ -3087,6 +3137,21 @@ func newDriveCommand() *cobra.Command {
})
driveRevertCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
driveRevertCmd.Flags().Int("version", 0, "要回滚到的历史版本号 (必填,正整数)")
for _, alias := range []string{"url", "id"} {
driveRevertCmd.Flags().String(alias, "", "--node 的别名")
_ = driveRevertCmd.Flags().MarkHidden(alias)
}
RegisterCrossProductAliases(driveRevertCmd)
for _, child := range driveStarCmd.Commands() {
for _, alias := range []string{"url", "id"} {
if child.Flags().Lookup(alias) == nil {
child.Flags().String(alias, "", "--node 的别名")
_ = child.Flags().MarkHidden(alias)
}
}
RegisterCrossProductAliases(child)
}
driveCmd.AddCommand(
driveListCmd,
+39 -18
View File
@@ -178,7 +178,7 @@ func newDocDepthRoute() driveDepthRoute {
}
// SIGINT 检查两点(出队后发首页前 + 翻页循环发每页前),入队是纯内存操作不检查。
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool) error {
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool, latest int) error {
if deps.Caller.DryRun() {
return printDriveDepthDryRun(route, baseArgs, maxDepth)
}
@@ -209,7 +209,7 @@ func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[s
bfs:
for len(queue) > 0 {
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern)
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
}
folder := queue[0]
queue = queue[1:]
@@ -220,7 +220,7 @@ bfs:
pages := 0
for {
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern)
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
}
pages++
if pages > maxPagesPerFolder {
@@ -233,7 +233,7 @@ bfs:
args := route.buildArgs(baseArgs, folder.id, pageToken)
text, err := route.fetchPage(ctx, args)
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern)
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
}
if err != nil {
folderErr = err
@@ -249,6 +249,12 @@ bfs:
item["depth"] = folder.depth + 1
item["parentId"] = folder.id // 根级为空串
item["rel_path"] = rel // 不保证唯一,组树以 parentId 为准
// 时间戳归一:钉盘 modifyTime / 知识库 updateTime 统一为 sortTime(毫秒 int64)
if ms, ok := driveItemModifiedMillis(item); ok {
item["sortTime"] = ms
} else {
item["sortTime"] = int64(0)
}
collected = append(collected, item)
if len(collected) >= driveDepthMaxItems {
// 未访问目录不记 errors[](没失败只是没扫),避免 errors 数组被淹没
@@ -291,7 +297,7 @@ bfs:
if driveDepthUnrecoverable(folderErr) {
// partial 照吐 stdout,错误详情走 stderr,非零退出
errs = append(errs, newDriveDepthError(folder, folderErr))
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern); emitErr != nil {
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth); emitErr != nil {
return emitErr
}
return folderErr
@@ -324,18 +330,26 @@ bfs:
}
}
return emitDriveDepthResult(collected, errs, truncated, pattern)
if truncated && latest > 0 {
return &CLIError{
Code: CodeContentTruncated,
Message: fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d", driveDepthMaxItems, latest),
Suggestion: fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 dws drive list --folder <子目录ID> --latest %d", latest),
}
}
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth)
}
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string) error {
if err := emitDriveDepthResult(items, errs, true, pattern); err != nil {
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int) error {
if err := emitDriveDepthResult(items, errs, true, pattern, latest, reqDepth); err != nil {
return err
}
return &driveDepthCancelledError{}
}
// depth>1 不输出 nextToken。
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string) error {
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string, latest, reqDepth int) error {
if pattern != "" {
// 先递归后过滤,过滤仅作用于输出项,不阻止文件夹下钻
filtered := make([]map[string]any, 0, len(items))
@@ -350,21 +364,28 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
}
items = filtered
}
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
sort.SliceStable(items, func(i, j int) bool {
ri, _ := items[i]["rel_path"].(string)
rj, _ := items[j]["rel_path"].(string)
if ri != rj {
return ri < rj
}
return driveDepthItemID(items[i]) < driveDepthItemID(items[j])
})
if latest > 0 {
items = applyDriveListLatest(items, latest)
} else {
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
sort.SliceStable(items, func(i, j int) bool {
ri, _ := items[i]["rel_path"].(string)
rj, _ := items[j]["rel_path"].(string)
if ri != rj {
return ri < rj
}
return driveDepthItemID(items[i]) < driveDepthItemID(items[j])
})
}
maxDepth := 0
for _, item := range items {
if d, ok := item["depth"].(int); ok && d > maxDepth {
maxDepth = d
}
}
if latest > 0 && reqDepth == 1 {
stripDriveDepthDecorations(items)
}
if items == nil {
items = []map[string]any{}
}
+17 -17
View File
@@ -306,7 +306,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
{"name": "a-file.xlsx", "rel_path": "a", "depth": 2, "fileId": "f1"},
{"name": "skip-me.csv", "rel_path": "c", "depth": 1, "fileId": "f3"},
}
if err := emitDriveDepthResult(items, nil, false, "*.xlsx"); err != nil {
if err := emitDriveDepthResult(items, nil, false, "*.xlsx", 0, 0); err != nil {
t.Fatal(err)
}
result := decodeDepthResult(t, out)
@@ -325,7 +325,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
}
out.Reset()
if err := emitDriveDepthResult(nil, nil, false, ""); err != nil {
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err != nil {
t.Fatal(err)
}
result = decodeDepthResult(t, out)
@@ -338,7 +338,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
{"name": "dup", "rel_path": "p/dup", "fileId": "a1"},
}
out.Reset()
if err := emitDriveDepthResult(samePath, nil, false, ""); err != nil {
if err := emitDriveDepthResult(samePath, nil, false, "", 0, 0); err != nil {
t.Fatal(err)
}
got = decodeDepthResult(t, out)["items"].([]any)
@@ -347,7 +347,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
}
deps.Out.w = failingWriter{}
if err := emitDriveDepthResult(nil, nil, false, ""); err == nil {
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err == nil {
t.Fatal("failing writer returned nil")
}
}
@@ -382,7 +382,7 @@ func runDepthBFS(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute
t.Helper()
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true)
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true, 0)
return decodeDepthResult(t, out), err
}
@@ -390,7 +390,7 @@ func TestCrossPlatformCoverageRunDriveListDepthDryRun(t *testing.T) {
caller := &scriptedToolCaller{format: "json", dry: true}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true); err != nil {
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0); err != nil {
t.Fatal(err)
}
if caller.calls != 0 {
@@ -413,7 +413,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPanBFS(t *testing.T) {
}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false); err != nil {
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false, 0); err != nil {
t.Fatal(err)
}
if caller.calls != 2 {
@@ -548,7 +548,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRateLimitResumesFromFailedPage(t
deps.Out.w = out
deps.Out.errW = io.Discard
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true); err != nil {
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 3 {
@@ -593,7 +593,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRootFailure(t *testing.T) {
}}
installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
if err == nil {
t.Fatal("root failure returned nil")
}
@@ -610,7 +610,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverable(t *testing.T) {
}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
if err == nil {
t.Fatal("unrecoverable returned nil")
}
@@ -635,7 +635,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverableEmitFailure(t *testi
installDepthCaller(t, caller)
deps.Out.w = failingWriter{}
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
if err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("err = %v, want emit failure", err)
}
@@ -648,7 +648,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPaginationLoop(t *testing.T) {
}}
installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
if err == nil || !strings.Contains(err.Error(), "cursor loop suspected") {
t.Fatalf("err = %v, want pagination anomaly", err)
}
@@ -690,7 +690,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelled(t *testing.T) {
cancel()
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
@@ -710,7 +710,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledEmitFailure(t *testing.T
cancel()
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
if err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("err = %v, want emit failure", err)
}
@@ -724,7 +724,7 @@ func TestCrossPlatformCoverageRunDriveListDepthFinalEmitFailure(t *testing.T) {
installDepthCaller(t, caller)
deps.Out.w = failingWriter{}
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
if err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("err = %v, want emit failure", err)
}
@@ -772,7 +772,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledInsidePagination(t *test
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true)
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true, 0)
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
@@ -801,7 +801,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledAfterFetch(t *testing.T)
deps.Out.errW = io.Discard
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
+199
View File
@@ -0,0 +1,199 @@
package helpers
import (
"context"
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
)
const (
// --latest 上限与 --limit 服务端每页硬上限 50 对齐。
driveLatestMax = 50
// 钉盘单层 latest 扫描上限(50×20 页)。
driveLatestScanMax = 1000
)
// validateDriveListLatest --latest 边界与互斥校验。
func validateDriveListLatest(cmd *cobra.Command, latest int) error {
if latest < 1 || latest > driveLatestMax {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--latest 必须为 1~%d 的整数,当前: %d", driveLatestMax, latest),
}
}
for _, f := range []string{"order-by", "order"} {
if cmd.Flags().Changed(f) {
return driveLatestExclusiveError(f, latest)
}
}
if cmd.Flags().Changed("limit") || cmd.Flags().Changed("max") {
return driveLatestExclusiveError("limit", latest)
}
if v := flagOrFallback(cmd, "cursor", "next-token"); v != "" {
return driveLatestExclusiveError("cursor", latest)
}
return nil
}
func driveLatestExclusiveError(flag string, latest int) error {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--latest 不能与 --%s 同时使用:Top-N 排序语义由 latest 独占;如需自定义排序请改用 --order-by modifyTime --order desc --limit %d", flag, latest),
}
}
func applyDriveListLatest(items []map[string]any, latest int) []map[string]any {
files := make([]map[string]any, 0, len(items))
for _, item := range items {
if isDriveDepthFolder(item) || isDocDepthFolder(item) {
continue
}
files = append(files, item)
}
sort.SliceStable(files, func(i, j int) bool {
ti, _ := files[i]["sortTime"].(int64)
tj, _ := files[j]["sortTime"].(int64)
if ti != tj {
return ti > tj
}
ri, _ := files[i]["rel_path"].(string)
rj, _ := files[j]["rel_path"].(string)
if ri != rj {
return ri < rj
}
return driveDepthItemID(files[i]) < driveDepthItemID(files[j])
})
if len(files) > latest {
files = files[:latest]
}
return files
}
func stripDriveDepthDecorations(items []map[string]any) {
for _, item := range items {
delete(item, "depth")
delete(item, "parentId")
delete(item, "rel_path")
delete(item, "sortTime")
}
}
func driveItemModifiedMillis(item map[string]any) (int64, bool) {
for _, k := range []string{"modifiedTime", "modifyTime", "modified_time", "gmtModified", "lastModifiedTime", "updateTime"} {
if v, ok := item[k]; ok {
if ms, ok := toMillis(v); ok {
return ms, true
}
}
}
return 0, false
}
func toMillis(v any) (int64, bool) {
switch t := v.(type) {
case float64:
if t <= 0 {
return 0, false
}
return int64(t), true
case json.Number:
if n, err := t.Int64(); err == nil && n > 0 {
return n, true
}
case string:
s := strings.TrimSpace(t)
if s == "" {
return 0, false
}
if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 {
return n, true
}
if tm, err := time.Parse(time.RFC3339, s); err == nil {
return tm.UnixMilli(), true
}
}
return 0, false
}
// runDriveListLatest 钉盘单层 --latest 扫描。
func runDriveListLatest(cmd *cobra.Command, baseArgs map[string]any, rootFolder string, latest int, pattern string, quiet bool) error {
buildArgs := func(pageToken string) map[string]any {
args := map[string]any{
"maxResults": float64(driveDepthPageSize),
"orderBy": "modifyTime",
"order": "desc",
}
for k, v := range baseArgs {
args[k] = v
}
if rootFolder != "" {
args["parentId"] = rootFolder
}
if pageToken != "" {
args["nextToken"] = pageToken
}
return args
}
if deps.Caller.DryRun() {
return deps.Out.PrintJSON(map[string]any{
"tool": "list_files",
"args": buildArgs(""),
"latest": latest,
"note": "dry-run:latest 为客户端能力,凑够 N 条即停,最多扫描 1000 条",
})
}
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
collected := make([]map[string]any, 0, latest)
scanned := 0
pageToken := ""
maxPages := driveLatestScanMax/driveDepthPageSize + 1
for pages := 0; pages < maxPages; pages++ {
text, err := callMCPToolReturnText(ctx, "list_files", buildArgs(pageToken))
if err != nil {
return fmt.Errorf("latest 扫描第 %d 页失败: %w", pages+1, err)
}
items, next, _ := parseDriveDepthPage(text)
for _, item := range items {
scanned++
if isDriveDepthFolder(item) {
continue
}
name, _ := item["name"].(string)
if name == "" {
name, _ = item["fileName"].(string)
}
if pattern != "" && !matchDriveNamePattern(name, pattern) {
continue
}
collected = append(collected, item)
if len(collected) >= latest {
break
}
}
if len(collected) >= latest || next == "" || scanned >= driveLatestScanMax {
break
}
pageToken = next
if !quiet {
fmt.Fprintf(os.Stderr, "[drive-list] latest 扫描中: 已扫 %d 条,命中 %d/%d\n", scanned, len(collected), latest)
}
}
if len(collected) < latest {
hint := fmt.Sprintf("dws drive list --folder <子目录ID> --latest %d", latest)
if pattern != "" {
hint = fmt.Sprintf("dws drive list --folder <子目录ID> --pattern %q --latest %d", pattern, latest)
}
fmt.Fprintf(os.Stderr, "[drive-list] 已扫描 %d 条,找到 %d/%d 条;建议缩小范围:%s\n", scanned, len(collected), latest, hint)
}
return deps.Out.PrintJSON(map[string]any{"items": collected})
}
+489 -3
View File
@@ -195,7 +195,72 @@ func newMailCommand() *cobra.Command {
mailboxProfileCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd)
mailboxSharedWithMeCmd := &cobra.Command{
Use: "shared-with-me",
Short: "查询共享给我的邮箱",
Long: `查询他人共享给当前用户的邮箱账号列表,包含共享关系类型。
共享关系(relationships)取值:
LOGIN 登录(可登录该共享邮箱)
SEND_AS 代发(以该邮箱身份发送邮件)
SEND_ON_BEHALF 代表发送(代表该邮箱发送邮件)
返回字段:
total 可访问的共享账号总数
targets 可访问的共享账号列表`,
Example: ` dws mail mailbox shared-with-me
dws mail mailbox shared-with-me --limit 20 --offset 0`,
RunE: func(cmd *cobra.Command, args []string) error {
toolArgs := map[string]any{}
if cmd.Flags().Changed("limit") {
limit, _ := cmd.Flags().GetInt("limit")
toolArgs["limit"] = limit
}
if cmd.Flags().Changed("offset") {
offset, _ := cmd.Flags().GetInt("offset")
toolArgs["offset"] = offset
}
if len(toolArgs) == 0 {
return callMCPTool("list_shared_with_me", nil)
}
return callMCPTool("list_shared_with_me", toolArgs)
},
}
DeclareLeafMetadata(mailboxSharedWithMeCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "list_shared_with_me",
CanonicalPath: "mail.list_shared_with_me",
CLIPath: "mail mailbox shared-with-me",
PrimaryCLIPath: "mail mailbox shared-with-me",
},
Description: "查询共享给我的邮箱",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_shared_with_me"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询他人共享给当前用户的邮箱",
UseWhen: []string{"需要列出登录/代发/代表发送权限的共享邮箱时"},
AvoidWhen: []string{"列出自己邮箱用 mail mailbox list"},
Examples: []string{"dws mail mailbox shared-with-me"},
},
Parameters: []contract.ParamDecl{
{Name: "limit", Property: "limit", InterfaceType: "integer"},
{Name: "offset", Property: "offset", InterfaceType: "integer"},
},
},
})
mailboxSharedWithMeCmd.Flags().Int("limit", 0, "返回数量上限 (可选)")
mailboxSharedWithMeCmd.Flags().Int("offset", 0, "偏移量 (可选)")
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd, mailboxSharedWithMeCmd)
messageCmd := &cobra.Command{Use: "message", Short: "邮件管理", RunE: groupRunE}
@@ -2126,9 +2191,251 @@ internetMessageId 来源:message send / draft send / message reply / message r
messageVerifyCmd.Flags().String("email", "", "邮件所属邮箱地址 (必填)")
messageVerifyCmd.Flags().String("internet-message-id", "", "邮件的 internetMessageId (必填),取自发送类命令返回值")
messageExportCmd := &cobra.Command{
Use: "export",
Short: "导出/备份邮件(EML格式)",
Long: `导出指定邮件为 EML 格式文件并保存到本地。
不指定 --filename 时,默认以邮件主题作为文件名。
文件保存在当前工作目录下,扩展名为 .eml。
默认不覆盖同名文件,使用 --overwrite 强制覆盖。
注意:目前仅支持 100KB 以内的邮件导出。
编排流程:
1. 调用 get_email_by_message_id 获取邮件主题(用作默认文件名)
2. 调用 export_message_mime 获取 EML 内容
3. 将 EML 内容原子写入本地文件`,
Example: ` dws mail message export --email user@company.com --id <messageId>
dws mail message export --email user@company.com --id <messageId> --filename my-mail`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
return err
}
email := mustGetFlag(cmd, "email")
messageID := mustGetFlag(cmd, "id")
filename := mustGetFlag(cmd, "filename")
overwrite, _ := cmd.Flags().GetBool("overwrite")
if deps.Caller.DryRun() {
// Human plan summary (no "[DRY-RUN]" tag): Schema dry-run
// evidence classifies "操作:" + audited DryRun() as plan.
deps.Out.PrintKeyValue("操作", "导出邮件为 EML 文件")
deps.Out.PrintKeyValue("email", email)
deps.Out.PrintKeyValue("messageId", messageID)
if filename != "" {
deps.Out.PrintKeyValue("filename", filename)
}
deps.Out.PrintKeyValue("overwrite", fmt.Sprintf("%v", overwrite))
deps.Out.PrintKeyValue("编排", "get_email_by_message_id → export_message_mime → 写入本地 .eml 文件")
return nil
}
ctx := cmd.Context()
if filename == "" {
msgText, err := callMCPToolReturnText(ctx, "get_email_by_message_id", map[string]any{
"email": email,
"messageId": messageID,
})
if err != nil {
return fmt.Errorf("获取邮件信息失败: %w", err)
}
var msgData map[string]any
if err := json.Unmarshal([]byte(msgText), &msgData); err == nil {
data := msgData
if result, ok := data["result"].(map[string]any); ok {
data = result
}
if msg, ok := data["message"].(map[string]any); ok {
data = msg
}
if subj, ok := data["subject"].(string); ok && subj != "" {
filename = subj
}
}
if filename == "" {
filename = messageID
}
}
filename = sanitizeMailFilename(filename)
exportText, err := callMCPToolReturnText(ctx, "export_message_mime", map[string]any{
"email": email,
"id": messageID,
})
if err != nil {
return fmt.Errorf("导出邮件失败: %w", err)
}
var exportData map[string]any
if err := json.Unmarshal([]byte(exportText), &exportData); err != nil {
return fmt.Errorf("解析导出结果失败: %w", err)
}
if result, ok := exportData["result"].(map[string]any); ok {
exportData = result
}
emlContent, _ := exportData["emlContent"].(string)
if emlContent == "" {
return fmt.Errorf("导出结果为空: %s", exportText)
}
destPath := filename + ".eml"
if err := atomicWriteFile(destPath, []byte(emlContent), 0600, overwrite); err != nil {
if os.IsExist(err) {
return fmt.Errorf("文件 %s 已存在,使用 --overwrite 覆盖", destPath)
}
return fmt.Errorf("保存文件失败: %w", err)
}
deps.Out.PrintInfo(fmt.Sprintf("邮件已导出到: %s", destPath))
return nil
},
}
DeclareLeafMetadata(messageExportCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "export_message_mime",
CanonicalPath: "mail.export_message_mime",
CLIPath: "mail message export",
PrimaryCLIPath: "mail message export",
},
Description: "导出/备份邮件为本地 EML 文件",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Orchestrates get_email_by_message_id + export_message_mime + local file write.",
},
Selection: contract.SelectionSpec{
AgentSummary: "导出邮件为本地 EML 备份",
UseWhen: []string{"需要把单封邮件备份为本地 .eml 文件时"},
AvoidWhen: []string{"仅查看正文用 mail message get;分享到 IM 用 mail message share-to-chat"},
Examples: []string{"dws mail message export --email user@company.com --id <messageId>"},
},
Parameters: []contract.ParamDecl{
{Name: "email", Property: "email", Required: boolPtr(true)},
{Name: "id", Property: "id", Required: boolPtr(true)},
{Name: "filename", Property: "filename"},
{Name: "overwrite", Property: "overwrite", InterfaceType: "boolean"},
},
},
})
messageExportCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
messageExportCmd.Flags().String("id", "", "邮件ID (必填)")
messageExportCmd.Flags().String("filename", "", "导出文件名(不含扩展名),默认使用邮件主题")
messageExportCmd.Flags().Bool("overwrite", false, "是否覆盖同名文件,默认 false")
messageShareToChatCmd := &cobra.Command{
Use: "share-to-chat",
Short: "[危险] 分享邮件至IM聊天",
Long: `将指定邮件分享到钉钉单聊。
参数说明:
--users 目标用户UID列表,逗号分隔(规范名),兼容 --uids
--yes 跳过二次确认,直接执行分享
服务端可能返回风险提示(riskMessage)和 sign,此时需要用户确认后
携带 sign 重新请求。默认会展示风险提示并中止,传入 --yes 可跳过确认。`,
Example: ` dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1,uid2
dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
return err
}
mcpArgs := map[string]any{
"email": mustGetFlag(cmd, "email"),
"id": mustGetFlag(cmd, "id"),
}
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
mcpArgs["uids"] = parseRecipients(users)
}
skipConfirm, _ := cmd.Flags().GetBool("yes")
if deps.Caller.DryRun() {
// Human plan summary (no "[DRY-RUN]" tag): Schema dry-run
// evidence classifies "操作:" + audited DryRun() as plan.
deps.Out.PrintKeyValue("操作", "分享邮件至 IM 聊天")
deps.Out.PrintKeyValue("email", mustGetFlag(cmd, "email"))
deps.Out.PrintKeyValue("messageId", mustGetFlag(cmd, "id"))
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
deps.Out.PrintKeyValue("users", users)
}
deps.Out.PrintKeyValue("yes", fmt.Sprintf("%v", skipConfirm))
deps.Out.PrintKeyValue("说明", "仅预览分享计划,不发起真实分享请求")
return nil
}
ctx := cmd.Context()
firstText, err := callMCPToolReturnText(ctx, "share_message_to_chat", mcpArgs)
if err != nil {
return fmt.Errorf("分享邮件失败: %w", err)
}
var firstResult map[string]any
if err := json.Unmarshal([]byte(firstText), &firstResult); err != nil {
return fmt.Errorf("解析分享结果失败: %w", err)
}
if result, ok := firstResult["result"].(map[string]any); ok {
firstResult = result
}
if sign, ok := firstResult["sign"].(string); ok && sign != "" {
riskMsg, _ := firstResult["riskMessage"].(string)
if !skipConfirm {
if riskMsg != "" {
deps.Out.PrintInfo(fmt.Sprintf("[风险提示] %s", riskMsg))
}
return fmt.Errorf("服务端要求二次确认,请添加 --yes 参数确认后重新执行")
}
mcpArgs["sign"] = sign
return callMCPTool("share_message_to_chat", mcpArgs)
}
// firstResult is already a parsed object (possibly unwrapped from result).
return deps.Out.PrintJSON(firstResult)
},
}
DeclareLeafMetadata(messageShareToChatCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "share_message_to_chat",
CanonicalPath: "mail.share_message_to_chat",
CLIPath: "mail message share-to-chat",
PrimaryCLIPath: "mail message share-to-chat",
},
Description: "分享邮件至 IM 单聊",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "share_message_to_chat"},
},
Selection: contract.SelectionSpec{
AgentSummary: "把邮件分享到钉钉单聊",
UseWhen: []string{"需要将指定邮件分享给钉钉用户(单聊)时"},
AvoidWhen: []string{"仅导出本地备份用 mail message export;群聊发消息用 chat message send"},
Examples: []string{"dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1"},
},
Parameters: []contract.ParamDecl{
{Name: "email", Property: "email", Required: boolPtr(true)},
{Name: "id", Property: "id", Required: boolPtr(true)},
{Name: "users", Property: "uids"},
{Name: "yes", Property: "yes", InterfaceType: "boolean"},
},
},
})
messageShareToChatCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
messageShareToChatCmd.Flags().String("id", "", "邮件ID (必填)")
messageShareToChatCmd.Flags().String("users", "", "目标用户UID列表,逗号分隔")
messageShareToChatCmd.Flags().String("uids", "", "--users 的别名")
_ = messageShareToChatCmd.Flags().MarkHidden("uids")
messageShareToChatCmd.Flags().Bool("yes", false, "跳过二次确认,直接执行分享")
messageCmd.AddCommand(messageListCmd, messageSearchCmd, messageGetCmd, messageSendCmd,
messageReplyCmd, messageReplyAllCmd, messageForwardCmd,
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd)
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd, messageExportCmd, messageShareToChatCmd)
sentMessageCmd := &cobra.Command{Use: "sent-message", Short: "已发送邮件管理", RunE: groupRunE}
@@ -3400,7 +3707,129 @@ object 与 operation 合法组合:
blockListRemoveCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)")
blockListCmd.AddCommand(blockListListCmd, blockListAddCmd, blockListRemoveCmd)
root.AddCommand(mailboxCmd, messageCmd, sentMessageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd, allowListCmd, blockListCmd)
calendarCmd := &cobra.Command{Use: "calendar", Short: "邮箱日历管理", RunE: groupRunE}
calendarListCmd := &cobra.Command{
Use: "list",
Short: "列出用户可访问的日历列表",
Long: `列出用户可访问的日历列表,包括用户自己创建以及接受共享后生成的日历。
返回的 id 可作为 calendar-event list 的 --id / --folder-id 使用。`,
Example: ` dws mail calendar list --email user@company.com`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email"); err != nil {
return err
}
return callMCPTool("list_mailbox_calendars", map[string]any{
"email": mustGetFlag(cmd, "email"),
})
},
}
DeclareLeafMetadata(calendarListCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "list_mailbox_calendars",
CanonicalPath: "mail.list_mailbox_calendars",
CLIPath: "mail calendar list",
PrimaryCLIPath: "mail calendar list",
},
Description: "列出邮箱日历文件夹",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_mailbox_calendars"},
},
Selection: contract.SelectionSpec{
AgentSummary: "列出邮箱可访问的日历文件夹",
UseWhen: []string{"查询邮箱日历文件夹 id,以便继续查日程时"},
AvoidWhen: []string{"钉钉主日历日程请用 dws calendar event list"},
Examples: []string{"dws mail calendar list --email user@company.com"},
},
Parameters: []contract.ParamDecl{
{Name: "email", Property: "email", Required: boolPtr(true)},
},
},
})
calendarListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
calendarCmd.AddCommand(calendarListCmd)
calendarEventCmd := &cobra.Command{Use: "calendar-event", Short: "邮箱日历日程管理", RunE: groupRunE}
calendarEventListCmd := &cobra.Command{
Use: "list",
Short: "查询指定日历时间范围内的日程",
Long: `查询指定邮箱日历文件夹在 UTC 时间区间 [startTime, endTime) 内出现的日程,支持 cursor 分页。循环日程会展开为该时间范围内的单次日程。`,
Example: ` dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start "2026-07-01T00:00:00Z" --end "2026-07-31T23:59:59Z"
dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start "2026-07-01T00:00:00Z" --end "2026-07-31T23:59:59Z" --cursor <cursor>`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email", "start", "end"); err != nil {
return err
}
if err := validateRequiredFlagWithAliases(cmd, "id", "folder-id"); err != nil {
return err
}
toolArgs := map[string]any{
"email": mustGetFlag(cmd, "email"),
"id": flagOrFallback(cmd, "id", "folder-id"),
"startTime": flagOrFallback(cmd, "start", "start-time"),
"endTime": flagOrFallback(cmd, "end", "end-time"),
}
if cursor := mustGetFlag(cmd, "cursor"); cursor != "" {
toolArgs["cursor"] = cursor
}
return callMCPTool("list_mailbox_calendar_events", toolArgs)
},
}
DeclareLeafMetadata(calendarEventListCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "list_mailbox_calendar_events",
CanonicalPath: "mail.list_mailbox_calendar_events",
CLIPath: "mail calendar-event list",
PrimaryCLIPath: "mail calendar-event list",
},
Description: "查询邮箱日历文件夹时间范围内的日程",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_mailbox_calendar_events"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询邮箱日历日程列表",
UseWhen: []string{"已知邮箱日历文件夹 id,需要按 UTC 时间窗列出日程时"},
AvoidWhen: []string{"钉钉主日历请用 dws calendar event list;未知文件夹 id 时先 mail calendar list"},
Examples: []string{"dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start \"2026-07-01T00:00:00Z\" --end \"2026-07-31T23:59:59Z\""},
},
Parameters: []contract.ParamDecl{
{Name: "email", Property: "email", Required: boolPtr(true)},
{Name: "id", Property: "id", Required: boolPtr(true)},
{Name: "start", Property: "startTime", Required: boolPtr(true)},
{Name: "end", Property: "endTime", Required: boolPtr(true)},
{Name: "cursor", Property: "cursor"},
},
},
})
calendarEventListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
calendarEventListCmd.Flags().String("id", "", "日历文件夹ID (必填)")
calendarEventListCmd.Flags().String("folder-id", "", "--id 的别名")
_ = calendarEventListCmd.Flags().MarkHidden("folder-id")
calendarEventListCmd.Flags().String("start", "", "视图开始UTC时间 (必填)")
calendarEventListCmd.Flags().String("start-time", "", "--start 的别名")
_ = calendarEventListCmd.Flags().MarkHidden("start-time")
calendarEventListCmd.Flags().String("end", "", "视图结束UTC时间 (必填)")
calendarEventListCmd.Flags().String("end-time", "", "--end 的别名")
_ = calendarEventListCmd.Flags().MarkHidden("end-time")
calendarEventListCmd.Flags().String("cursor", "", "分页光标 (可选)")
calendarEventCmd.AddCommand(calendarEventListCmd)
root.AddCommand(mailboxCmd, messageCmd, sentMessageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd, allowListCmd, blockListCmd, calendarCmd, calendarEventCmd)
return root
}
@@ -3935,3 +4364,60 @@ func runMailAttachmentDownload(cmd *cobra.Command) error {
deps.Out.PrintInfo(fmt.Sprintf("附件已保存到: %s", destPath))
return nil
}
func sanitizeMailFilename(name string) string {
name = strings.TrimSpace(name)
name = strings.ReplaceAll(name, "/", "_")
name = strings.ReplaceAll(name, "\\", "_")
name = strings.ReplaceAll(name, "\x00", "")
if name == "" {
return "mail"
}
return name
}
// mailAtomicLink is the no-clobber commit for atomicWriteFile (test-injectable).
var mailAtomicLink = os.Link
// atomicWriteFile 原子写入文件:先写同目录临时文件,成功后提交到目标路径。
// overwrite=false 使用 link(2) 实现存在即失败;overwrite=true 使用 rename 覆盖。
func atomicWriteFile(path string, data []byte, perm os.FileMode, overwrite bool) error {
dir := filepath.Dir(path)
tmp, err := atomicCreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("创建临时文件失败: %w", err)
}
tmpName := tmp.Name()
success := false
defer func() {
if !success {
_ = tmp.Close()
_ = atomicRemove(tmpName)
}
}()
if err := tmp.Chmod(perm); err != nil {
return fmt.Errorf("设置文件权限失败: %w", err)
}
if _, err := tmp.Write(data); err != nil {
return fmt.Errorf("写入数据失败: %w", err)
}
if err := tmp.Sync(); err != nil {
return fmt.Errorf("同步磁盘失败: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("关闭临时文件失败: %w", err)
}
if overwrite {
if err := atomicRename(tmpName, path); err != nil {
return fmt.Errorf("重命名文件失败: %w", err)
}
success = true
return nil
}
if err := mailAtomicLink(tmpName, path); err != nil {
return err
}
_ = atomicRemove(tmpName)
success = true
return nil
}
+2 -1
View File
@@ -49,12 +49,13 @@ func newMarkdownCommand() *cobra.Command {
root := &cobra.Command{
Use: "markdown",
Short: "Markdown 文件处理",
Long: "创建、覆盖、修补和获取钉盘或文档空间中的原生 Markdown 文件。",
Long: "创建、覆盖、修补、对比和获取钉盘或文档空间中的原生 Markdown 文件。",
RunE: groupRunE,
}
root.AddCommand(
newMarkdownFetchCmd(),
newMarkdownCreateCmd(),
newMarkdownDiffCmd(),
newMarkdownOverwriteCmd(),
newMarkdownPatchCmd(),
)
+491
View File
@@ -0,0 +1,491 @@
package helpers
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// ──────────────────────────────────────────────────────────
// dws markdown diff
// ──────────────────────────────────────────────────────────
// diffResult 是 diff 命令的输出结构。
type diffResult struct {
Mode string `json:"mode"`
Changed bool `json:"changed"`
AddedLines int `json:"added_lines"`
DeletedLines int `json:"deleted_lines"`
Hunks int `json:"hunks"`
Diff string `json:"diff"`
}
// diff 命令的限制(包级变量以便覆盖测试注入更小阈值)。
var (
maxDiffFileSize int64 = 10 * 1024 * 1024 // 单侧文件大小上限 10MB
diffDownloadTimeout = 10 * time.Minute // 下载远端内容超时(与项目其他下载命令一致)
diffComputeTimeout = 30 * time.Second // 本地 diff 计算超时
diffJSONMarshalIndent = json.MarshalIndent
runMarkdownUnifiedDiff = computeUnifiedDiff
)
// formatFileSize 返回人类可读的文件大小。
func formatFileSize(size int64) string {
if size >= 1024*1024 {
return fmt.Sprintf("%.1f MB", float64(size)/float64(1024*1024))
}
if size >= 1024 {
return fmt.Sprintf("%.1f KB", float64(size)/float64(1024))
}
return fmt.Sprintf("%d B", size)
}
// checkFileSize 校验文件大小是否超过限制。
func checkFileSize(path string) error {
info, err := os.Stat(path)
if err != nil {
return err
}
if info.Size() > maxDiffFileSize {
return fmt.Errorf("文件大小 %s 超过限制 %s,请使用更小的文件", formatFileSize(info.Size()), formatFileSize(maxDiffFileSize))
}
return nil
}
// downloadRemoteContent 下载远端文件内容并返回其文本。
// versionNum <= 0 时走 download_file(用 fileId),> 0 时走 download_file_version(用 nodeId + version)。
func downloadRemoteContent(ctx context.Context, fileID string, versionNum int) (string, error) {
var text string
var err error
if versionNum > 0 {
text, err = callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", map[string]any{
"nodeId": fileID,
"version": versionNum,
})
} else {
text, err = callMCPToolReturnTextOnServer(ctx, "drive", "download_file", map[string]any{
"fileId": fileID,
})
}
if err != nil {
return "", err
}
resourceURL, dlHeaders, err := parseDownloadInfo(text)
if err != nil {
return "", err
}
// 下载时即限制大小,避免完整下载超大文件后才拦截(约束网络流量与内存占用)
content, err := diffDownloadLimited(ctx, resourceURL, dlHeaders)
if err != nil {
return "", err
}
return string(content), nil
}
// diffDownloadLimited 下载远端内容,并在下载过程中强制执行 maxDiffFileSize 上限。
// 包级变量以便测试注入。
var diffDownloadLimited = defaultDiffDownloadLimited
// defaultDiffDownloadLimited 通过 HTTP GET 下载内容:先用 Content-Length 预检,
// 再用 io.LimitReader 将实际读取量限制为 maxDiffFileSize+1 字节,超限即报错,
// 从而约束网络流量与内存占用,而非在完整下载后才校验。
func defaultDiffDownloadLimited(ctx context.Context, url string, headers map[string]string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
client := &http.Client{Timeout: diffDownloadTimeout}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
// Content-Length 预检:可在读取 body 前提前拦截超大文件
if resp.ContentLength > maxDiffFileSize {
return nil, fmt.Errorf("远端文件大小 %s 超过限制 %s,请使用更小的文件", formatFileSize(resp.ContentLength), formatFileSize(maxDiffFileSize))
}
// 实际读取限制为 maxDiffFileSize+1 字节,读满即判定超限(防止 Content-Length 缺失或造假)
data, err := io.ReadAll(io.LimitReader(resp.Body, maxDiffFileSize+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxDiffFileSize {
return nil, fmt.Errorf("远端文件大小超过限制 %s,请使用更小的文件", formatFileSize(maxDiffFileSize))
}
return data, nil
}
// computeUnifiedDiff 使用 Go stdlib patience diff 算法计算 unified diff,并统计变更行数。
func computeUnifiedDiff(left, right string, contextLines int) (string, int, int, int, bool) {
out := UnifiedDiff("left", []byte(left), "right", []byte(right), contextLines)
if len(out) == 0 {
return "", 0, 0, 0, false
}
text := string(out)
added, deleted, hunks := 0, 0, 0
// 只统计首个 @@ 之后的 hunk 区行:头部三行(diff/---/+++)不参与计数,
// hunk 区内每行必带单字符前缀,内容行以 --/++ 开头也不会被误判为文件头而漏计
inHunk := false
for _, line := range strings.Split(text, "\n") {
if strings.HasPrefix(line, "@@") {
hunks++
inHunk = true
continue
}
if !inHunk {
continue
}
if strings.HasPrefix(line, "+") {
added++
} else if strings.HasPrefix(line, "-") {
deleted++
}
}
changed := added > 0 || deleted > 0
return text, added, deleted, hunks, changed
}
// ensureMarkdownDiffType 校验 markdown diff 的目标文件类型。
// markdown 产品域面向 .md 文件,非 md 文件拦截并回引到对应产品命令。
// 类型探测复用 fetchFileInfo(显式路由 drive server 的 get_file_info),
// 探测失败或类型未知时不阻断,让后续 MCP 工具自行报错。
func ensureMarkdownDiffType(ctx context.Context, nodeID string) error {
info := fetchFileInfo(ctx, nodeID)
switch info.extension {
case "", "md", "markdown":
return nil
case "adoc":
return fmt.Errorf("该文件为钉钉在线文档 (adoc),不支持 markdown diff\n请使用 dws doc 对应命令(如 dws doc version list / dws doc export)")
case "axls":
return fmt.Errorf("该文件为钉钉在线表格 (axls),不支持 markdown diff\n请使用 dws sheet 对应命令")
case "amind", "adraw":
return fmt.Errorf("该文件为钉钉在线%s (%s),暂不支持历史版本管理\nmarkdown diff 与 dws drive list --versions / dws drive download --version 均不支持该类型", describeDingTalkDocType(info.extension), info.extension)
default:
return fmt.Errorf("该文件为 %s 文件,markdown diff 仅支持 .md 文件\n普通文件的历史版本请使用 dws drive list --versions / dws drive download --version / dws drive revert", info.extension)
}
}
// newMarkdownDiffCmd 创建 markdown diff 子命令。
func newMarkdownDiffCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "diff",
Short: "比较 Markdown 内容差异",
Long: `比较远端 Markdown 文件的两个版本,或远端版本与本地文件,生成 unified diff。
模式:
remote_vs_remote: --version V1 --version2 V2 (两个历史版本)
remote_vs_remote: --version V1 (历史版本 vs 最新)
remote_vs_local: --file ./local.md (最新 vs 本地)
remote_vs_local: --version V1 --file ./local.md (历史版本 vs 本地)
历史版本号通过 dws drive list --versions 获取。
--file 与 --version2 不能同时使用。
限制:
- 仅支持 .md 文件(在线文档/表格请使用 doc/sheet 命令)
- 单侧文件大小上限: 10 MB
- 下载超时: 10 分钟
- diff 计算超时: 30 秒`,
Example: ` # 比较两个历史版本
dws markdown diff --node <dentryUuid> --version 3 --version2 5
# 历史版本 vs 最新版本
dws markdown diff --node <dentryUuid> --version 3
# 最新版本 vs 本地文件
dws markdown diff --node <dentryUuid> --file ./draft.md
# 历史版本 vs 本地文件
dws markdown diff --node <dentryUuid> --version 3 --file ./draft.md`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
version1, _ := cmd.Flags().GetInt("version")
version2, _ := cmd.Flags().GetInt("version2")
localFile, _ := cmd.Flags().GetString("file")
contextLines, _ := cmd.Flags().GetInt("context")
// fail-fast 参数校验(置于 dry-run 之前):显式传了版本号但非正整数时立即报错,
// 避免静默降级为“最新版本”;--context 允许 0(无上下文),仅拒绝负值
if cmd.Flags().Changed("version") && version1 <= 0 {
return fmt.Errorf("--version 必须为正整数,当前值: %d", version1)
}
if cmd.Flags().Changed("version2") && version2 <= 0 {
return fmt.Errorf("--version2 必须为正整数,当前值: %d", version2)
}
if contextLines < 0 {
return fmt.Errorf("--context 不能为负数,当前值: %d", contextLines)
}
// 互斥校验:--file 与 --version2 不能同时使用
if localFile != "" && version2 > 0 {
return fmt.Errorf("--file 与 --version2 不能同时使用")
}
// 模式判定
var mode string
if localFile != "" {
mode = "remote_vs_local"
} else {
mode = "remote_vs_remote"
}
// remote_vs_remote 模式至少需要一个版本号,否则两侧均取最新版本,diff 必为空
if localFile == "" && version1 == 0 && version2 == 0 {
return fmt.Errorf("remote_vs_remote 模式至少需要指定 --version 或 --version2 之一")
}
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "Markdown 内容 Diff")
deps.Out.PrintKeyValue("模式", mode)
deps.Out.PrintKeyValue("节点ID", nodeID)
if version1 > 0 {
deps.Out.PrintKeyValue("左侧版本", fmt.Sprintf("%d", version1))
} else {
deps.Out.PrintKeyValue("左侧版本", "最新")
}
if localFile != "" {
deps.Out.PrintKeyValue("右侧", localFile)
} else if version2 > 0 {
deps.Out.PrintKeyValue("右侧版本", fmt.Sprintf("%d", version2))
} else {
deps.Out.PrintKeyValue("右侧版本", "最新")
}
return nil
}
// 下载超时 context(与项目其他下载命令一致:10 分钟)
ctx, cancel := context.WithTimeout(context.Background(), diffDownloadTimeout)
defer cancel()
// 类型守卫置于 dry-run 之后,确保 dry-run 不产生任何服务端调用
if err := ensureMarkdownDiffType(ctx, nodeID); err != nil {
return err
}
// remote_vs_local 模式:先校验本地文件大小,避免下载后才发现过大
if localFile != "" {
if err := checkFileSize(localFile); err != nil {
return err
}
}
// 输出格式读全局 --format(默认 json),json 时输出结构化结果,其余输出文本摘要
isJSON := deps.Caller.Format() == "json"
// 进度属带外诊断信息,统一写入 stderr,保证 stdout 在两种模式下都是纯净输出
progress := func(msg string) { fmt.Fprintln(os.Stderr, msg) }
// 下载左侧内容
progress("[1/3] 获取左侧内容...")
leftContent, err := downloadRemoteContent(ctx, nodeID, version1)
if err != nil {
return err
}
// 获取右侧内容
var rightContent string
if localFile != "" {
// remote_vs_local: 读取本地文件(大小已校验)
progress("[2/3] 读取本地文件...")
data, err := os.ReadFile(localFile)
if err != nil {
return fmt.Errorf("读取本地文件失败: %w", err)
}
rightContent = string(data)
} else {
// remote_vs_remote: 下载右侧远端内容
progress("[2/3] 获取右侧内容...")
rightContent, err = downloadRemoteContent(ctx, nodeID, version2)
if err != nil {
return err
}
}
// 计算 diff(带超时保护)
progress("[3/3] 计算差异...")
type diffOutput struct {
text string
added int
deleted int
hunks int
changed bool
}
resultCh := make(chan diffOutput, 1)
// Capture seams before the goroutine so test restorers cannot race
// against a still-running compute after the timeout path returns.
computeDiff := runMarkdownUnifiedDiff
marshalIndent := diffJSONMarshalIndent
go func() {
text, added, deleted, hunks, changed := computeDiff(leftContent, rightContent, contextLines)
resultCh <- diffOutput{text, added, deleted, hunks, changed}
}()
select {
case res := <-resultCh:
diffText, added, deleted, hunks, changed := res.text, res.added, res.deleted, res.hunks, res.changed
result := diffResult{
Mode: mode,
Changed: changed,
AddedLines: added,
DeletedLines: deleted,
Hunks: hunks,
Diff: diffText,
}
// 输出
if isJSON {
data, err := marshalIndent(result, "", " ")
if err != nil {
return fmt.Errorf("JSON 序列化失败: %w", err)
}
deps.Out.PrintRaw(string(data))
} else {
deps.Out.PrintKeyValue("模式", result.Mode)
if result.Changed {
deps.Out.PrintKeyValue("是否有变更", "是")
} else {
deps.Out.PrintKeyValue("是否有变更", "否")
}
deps.Out.PrintKeyValue("新增行数", fmt.Sprintf("%d", result.AddedLines))
deps.Out.PrintKeyValue("删除行数", fmt.Sprintf("%d", result.DeletedLines))
deps.Out.PrintKeyValue("差异块数", fmt.Sprintf("%d", result.Hunks))
if result.Changed {
deps.Out.PrintRaw("")
deps.Out.PrintRaw(result.Diff)
}
}
case <-time.After(diffComputeTimeout):
return fmt.Errorf("diff 计算超时(%s),文件可能过大,请尝试减小 --context 或使用更小的文件", diffComputeTimeout)
}
return nil
},
}
cmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
cmd.Flags().Int("version", 0, "左侧历史版本号 (可选,不传=最新版本)")
cmd.Flags().Int("version2", 0, "右侧历史版本号 (可选,不传=最新版本;不能与 --file 同时使用)")
cmd.Flags().String("file", "", "本地 .md 文件路径 (可选,指定后进入 remote_vs_local 模式)")
cmd.Flags().Int("context", 3, "diff 上下文行数 (默认 3)")
// --node 隐藏别名(与 version/fetch 子命令一致)
cmd.Flags().String("url", "", "")
cmd.Flags().String("id", "", "")
cmd.Flags().String("node-id", "", "")
cmd.Flags().String("doc-id", "", "")
cmd.Flags().String("file-id", "", "")
_ = cmd.Flags().MarkHidden("url")
_ = cmd.Flags().MarkHidden("id")
_ = cmd.Flags().MarkHidden("node-id")
_ = cmd.Flags().MarkHidden("doc-id")
_ = cmd.Flags().MarkHidden("file-id")
RegisterCrossProductAliases(cmd)
cli.AnnotateRuntimeRequiredFlags(cmd, "node")
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "markdown",
Name: "diff",
CanonicalPath: "markdown.diff",
CLIPath: "markdown diff",
PrimaryCLIPath: "markdown diff",
},
Description: "比较远端 Markdown 文件的两个版本,或远端版本与本地文件,生成 unified diff",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Local diff workflow: download remote version(s) and/or read a local file, then compute unified diff client-side.",
},
Selection: contract.SelectionSpec{
AgentSummary: "比较 Markdown 文件版本或本地草稿差异",
UseWhen: []string{"需要对比远端 .md 历史版本,或远端最新/历史版本与本地草稿的差异时"},
AvoidWhen: []string{"在线文档/表格差异请用 doc/sheet;普通二进制文件版本请用 drive list --versions / drive download --version"},
Examples: []string{"dws markdown diff --node <nodeId> --version 3 --version2 5"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
{Name: "version", Property: "version", InterfaceType: "integer"},
{Name: "version2", Property: "version2", InterfaceType: "integer"},
{Name: "file", Property: "file"},
{Name: "context", Property: "context", InterfaceType: "integer"},
},
},
})
return cmd
}
// fetchFileInfo 通过 get_file_info 获取扩展名(markdown diff 类型守卫用)。
// 探测失败返回零值,由调用方决定是否阻断。
type markdownFileInfo struct {
name string
extension string
}
func fetchFileInfo(ctx context.Context, nodeID string) (info markdownFileInfo) {
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "get_file_info", map[string]any{"fileId": nodeID})
if err != nil {
return
}
var resp map[string]any
if err := json.Unmarshal([]byte(text), &resp); err != nil {
return
}
data := resp
if result, ok := resp["result"].(map[string]any); ok {
data = result
}
if name, ok := data["name"].(string); ok {
info.name = name
}
if ext, ok := data["extension"].(string); ok {
info.extension = strings.ToLower(strings.TrimPrefix(ext, "."))
}
return
}
func describeDingTalkDocType(ext string) string {
switch strings.ToLower(ext) {
case "adoc":
return "文档"
case "axls":
return "表格"
case "amind":
return "脑图"
case "adraw":
return "画图"
default:
return "文件"
}
}
+260 -10
View File
@@ -3,6 +3,7 @@ package helpers
import (
"fmt"
"strconv"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
@@ -18,7 +19,7 @@ func newMinutesCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "minutes",
Selection: contract.ProductSelectionDecl{
AgentSummary: "查询和维护钉钉听记的转写、摘要、待办、权限、录音、标签、说话人总结及文件上传会话。",
AgentSummary: "查询和维护钉钉听记的转写、摘要、待办、权限、录音、标签、说话人总结、语音备忘及文件上传会话。",
UseWhen: []string{
"用户要查找、读取、编辑或管理钉钉听记及其录音、转写、摘要和衍生内容。",
},
@@ -1282,7 +1283,10 @@ func newMinutesCommand() *cobra.Command {
Selection: contract.SelectionSpec{
AgentSummary: "添加听记个人热词,用于优化语音识别中专有名词、人名等的识别准确率。",
UseWhen: []string{"需要添加听记个人热词以优化专有名词/人名识别时(单词不超过约10汉字)"},
AvoidWhen: []string{"要查看已有热词时改用 dws minutes hot-word list"},
AvoidWhen: []string{
"要查看已有热词时改用 dws minutes hot-word list",
"要删除热词时改用 dws minutes hot-word delete",
},
Examples: []string{
"dws minutes hot-word add --words \"钉钉\"",
"dws minutes hot-word add --words \"OKR,钉钉,Copilot\"",
@@ -1328,13 +1332,70 @@ func newMinutesCommand() *cobra.Command {
Selection: contract.SelectionSpec{
AgentSummary: "查询当前用户配置的所有听记热词列表。",
UseWhen: []string{"需要查看当前用户已配置的听记个人热词列表时"},
AvoidWhen: []string{"要添加热词时改用 hot-word add"},
Examples: []string{"dws minutes hot-word list"},
AvoidWhen: []string{
"要添加热词时改用 hot-word add",
"要删除热词时改用 hot-word delete",
},
Examples: []string{"dws minutes hot-word list"},
},
},
})
hotWordCmd.AddCommand(hotWordAddCmd, hotWordListCmd)
hotWordDeleteCmd := &cobra.Command{
Use: "delete",
Short: "批量删除个人热词",
Long: `批量删除听记个人热词。
支持一次删除多个热词(逗号分隔)。删除后对应热词不再参与后续语音识别优化。`,
Example: ` dws minutes hot-word delete --words "钉钉"
dws minutes hot-word delete --words "OKR,钉钉,Copilot"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "words"); err != nil {
return err
}
return callMCPTool("delete_personal_hotword", map[string]any{
"hotWordList": parseCSVValues(mustGetFlag(cmd, "words")),
})
},
}
DeclareLeafMetadata(hotWordDeleteCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "minutes",
Name: "delete_personal_hotword",
CanonicalPath: "minutes.delete_personal_hotword",
CLIPath: "minutes hot-word delete",
PrimaryCLIPath: "minutes hot-word delete",
},
Description: "批量删除听记个人热词。删除后对应热词不再参与后续语音识别优化。",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "delete_personal_hotword"},
},
Selection: contract.SelectionSpec{
AgentSummary: "批量删除听记个人热词,清理误加或过时热词。",
UseWhen: []string{"用户要删除/移除已配置的听记个人热词时"},
AvoidWhen: []string{
"要添加热词时改用 hot-word add",
"不确定现有热词时先用 hot-word list",
},
Examples: []string{
"dws minutes hot-word delete --words \"钉钉\"",
"dws minutes hot-word delete --words \"OKR,钉钉,Copilot\"",
},
},
Parameters: []contract.ParamDecl{
{Name: "words", Property: "hotWordList"},
},
},
})
hotWordDeleteCmd.Flags().String("words", "", "要删除的热词,多个用逗号分隔 (必填)")
hotWordCmd.AddCommand(hotWordAddCmd, hotWordListCmd, hotWordDeleteCmd)
// ── replace-text 命令 ───────────────────────────────────────
replaceTextCmd := &cobra.Command{
@@ -1618,8 +1679,8 @@ func newMinutesCommand() *cobra.Command {
uploadCmd.AddCommand(uploadCreateCmd, uploadCompleteCmd, uploadCancelCmd)
// ── permission 子组 ─────────────────────────────────────────
// 听记成员权限管理:批量添加/移除成员及其权限。
// 对应 MCP 工具 add_member_permission / remove_member_permission。
// 听记成员权限管理:批量添加/移除成员及其权限、为当前用户申请权限。
// 对应 MCP 工具 add_member_permission / remove_member_permission / apply_minutes_permission。
permissionCmd := &cobra.Command{Use: "permission", Short: "听记成员权限管理", RunE: groupRunE}
// permission add — 对应 MCP 工具 add_member_permission
@@ -1709,6 +1770,7 @@ func newMinutesCommand() *cobra.Command {
UseWhen: []string{"已知听记 uuid,需要批量给听记增加成员并设置权限(policy 0管理员/1所有者/2可编辑/3可查看下载/4仅查看)时"},
AvoidWhen: []string{
"要移除成员权限时改用 dws minutes permission remove",
"当前用户自己申请访问权限时改用 dws minutes permission apply",
"成员、权限策略或听记 id 未确认时不要添加",
},
Examples: []string{
@@ -1784,6 +1846,7 @@ func newMinutesCommand() *cobra.Command {
UseWhen: []string{"用户明确要求批量移除听记成员权限,使其失去访问时"},
AvoidWhen: []string{
"要添加权限时改用 permission add",
"当前用户自己申请访问权限时改用 permission apply",
"成员或听记 id 未确认时不要移除",
},
Examples: []string{
@@ -1803,7 +1866,85 @@ func newMinutesCommand() *cobra.Command {
_ = permissionRemoveCmd.Flags().MarkHidden("task-uuids")
permissionRemoveCmd.Flags().String("member-uids", "", "成员钉钉 UID 列表,逗号分隔 (必填)")
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd)
// permission apply — 对应 MCP 工具 apply_minutes_permission
permissionApplyCmd := &cobra.Command{
Use: "apply",
Short: "为当前用户申请听记权限",
Long: `为当前登录用户申请指定听记的权限。
适用于用户无权限访问某听记(如打开分享链接提示无权限)时,主动向听记所有者发起权限申请。
权限类型 (--policy):
2 = 可编辑
3 = 可查看/下载
4 = 仅查看`,
Example: ` dws minutes permission apply --id <taskUuid> --policy 4
dws minutes permission apply --id <taskUuid> --policy 2`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlagWithAliases(cmd, "id", "url", "task-uuid", "uuid"); err != nil {
return err
}
if err := validateRequiredFlags(cmd, "policy"); err != nil {
return err
}
policyID, err := strconv.ParseInt(mustGetFlag(cmd, "policy"), 10, 64)
if err != nil || policyID < 2 || policyID > 4 {
return fmt.Errorf("flag --policy must be an integer between 2 and 4 (2=可编辑, 3=可查看/下载, 4=仅查看)")
}
return callMCPTool("apply_minutes_permission", map[string]any{
"taskUuid": flagOrFallback(cmd, "id", "url", "task-uuid", "uuid"),
"policyId": float64(policyID),
})
},
}
DeclareLeafMetadata(permissionApplyCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "minutes",
Name: "apply_minutes_permission",
CanonicalPath: "minutes.apply_minutes_permission",
CLIPath: "minutes permission apply",
PrimaryCLIPath: "minutes permission apply",
},
Description: "为当前登录用户申请指定听记的权限。",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "apply_minutes_permission"},
},
Selection: contract.SelectionSpec{
AgentSummary: "为当前登录用户申请指定听记的访问权限(可编辑/可查看下载/仅查看)。",
UseWhen: []string{"当前用户对某听记无权限,需要向所有者申请访问(policy 2/3/4)时"},
AvoidWhen: []string{
"所有者批量给他人加权限时改用 permission add",
"要移除他人权限时改用 permission remove",
},
Examples: []string{
"dws minutes permission apply --id <taskUuid> --policy 4",
"dws minutes permission apply --id <taskUuid> --policy 2",
},
},
Parameters: []contract.ParamDecl{
{Name: "id", Property: "taskUuid"},
{Name: "policy", Property: "policyId"},
},
},
})
permissionApplyCmd.Flags().String("id", "", "听记 taskUuid (必填)")
permissionApplyCmd.Flags().String("url", "", "--id 的别名")
_ = permissionApplyCmd.Flags().MarkHidden("url")
permissionApplyCmd.Flags().String("task-uuid", "", "--id 的别名 (兼容 OpenAPI 字段名)")
_ = permissionApplyCmd.Flags().MarkHidden("task-uuid")
permissionApplyCmd.Flags().String("uuid", "", "--id 的别名")
_ = permissionApplyCmd.Flags().MarkHidden("uuid")
permissionApplyCmd.Flags().String("policy", "", "权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)")
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd, permissionApplyCmd)
// ── tag 子组 ────────────────────────────────────────────────
// 听记标签/分组管理:查询用户标签列表、按标签查询听记。
@@ -1928,13 +2069,122 @@ func newMinutesCommand() *cobra.Command {
tagCmd.AddCommand(tagListCmd, tagQueryCmd)
// ── audio-memo 子组 ────────────────────────────
// 语音备忘查询:对应 MCP 工具 list_audio_memos。
// 用户身份由网关按登录态注入 uid,agent/CLI 无需传入。
// 返回值 items[].audioUrl 为带签名的音频 URL(含 &),因此使用
// callMCPToolUnescaped 输出,避免 & 被转义为 \u0026(与 upload 一致)。
audioMemoCmd := &cobra.Command{Use: "audio-memo", Short: "语音备忘查询", RunE: groupRunE}
audioMemoListCmd := &cobra.Command{
Use: "list",
Short: "查询语音备忘列表",
Long: `查询当前用户的语音备忘列表,支持分页和时间范围筛选。
分页:首页 --cursor 留空(或 0),后续把上一页返回的 nextCursor 回填到 --cursor。
时间范围:--start/--end 为 ISO-8601(可选),不传默认查询近一年。`,
Example: ` dws minutes audio-memo list
dws minutes audio-memo list --max 500
dws minutes audio-memo list --start "2026-01-01T00:00:00+08:00" --end "2026-07-21T23:59:59+08:00"
dws minutes audio-memo list --cursor 1740000000000`,
RunE: func(cmd *cobra.Command, args []string) error {
toolArgs := map[string]any{}
max, _ := cmd.Flags().GetFloat64("max")
if max <= 0 || max > 1000 {
return fmt.Errorf("flag --max must be between 1 and 1000")
}
toolArgs["pageSize"] = max
if cmd.Flags().Changed("cursor") {
cursor, _ := cmd.Flags().GetInt64("cursor")
if cursor < 0 {
return fmt.Errorf("flag --cursor must be >= 0")
}
toolArgs["cursor"] = float64(cursor)
}
startStr, _ := cmd.Flags().GetString("start")
endStr, _ := cmd.Flags().GetString("end")
// China Standard Time has no DST; FixedZone avoids zoneinfo nil-fallback branches.
loc := time.FixedZone("Asia/Shanghai", 8*3600)
var startMs, endMs int64
if startStr != "" {
var err error
startMs, err = parseISOTimeToMillis("start", startStr)
if err != nil {
return err
}
toolArgs["startTime"] = time.UnixMilli(startMs).In(loc).Format(time.RFC3339)
}
if endStr != "" {
var err error
endMs, err = parseISOTimeToMillis("end", endStr)
if err != nil {
return err
}
toolArgs["endTime"] = time.UnixMilli(endMs).In(loc).Format(time.RFC3339)
}
if startStr != "" && endStr != "" {
if err := validateTimeRange(startMs, endMs); err != nil {
return err
}
}
return callMCPToolUnescaped("list_audio_memos", toolArgs)
},
}
DeclareLeafMetadata(audioMemoListCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "minutes",
Name: "list_audio_memos",
CanonicalPath: "minutes.list_audio_memos",
CLIPath: "minutes audio-memo list",
PrimaryCLIPath: "minutes audio-memo list",
},
Description: "查询当前用户的语音备忘列表,支持分页和时间范围筛选。",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "list_audio_memos"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询当前用户的语音备忘列表(独立于听记列表与 get audio)。",
UseWhen: []string{"用户要查看语音备忘/录音备忘列表时(可带时间范围或翻页)"},
AvoidWhen: []string{
"要查听记列表改用 minutes list",
"只要某篇听记的音频地址改用 minutes get audio",
},
Examples: []string{
"dws minutes audio-memo list",
"dws minutes audio-memo list --start \"2026-01-01T00:00:00+08:00\" --end \"2026-07-21T23:59:59+08:00\"",
},
},
Parameters: []contract.ParamDecl{
{Name: "max", Property: "pageSize"},
{Name: "cursor", Property: "cursor"},
{Name: "start", Property: "startTime"},
{Name: "end", Property: "endTime"},
},
},
})
audioMemoListCmd.Flags().Float64("max", 200, "每页数据条数 (默认 200,上限 1000)")
audioMemoListCmd.Flags().Int64("cursor", 0, "翻页游标,回填上一页返回的 nextCursor (首页留空)")
audioMemoListCmd.Flags().String("start", "", "开始时间 ISO-8601 (可选,默认近一年)")
audioMemoListCmd.Flags().String("end", "", "结束时间 ISO-8601 (可选)")
audioMemoCmd.AddCommand(audioMemoListCmd)
minutesCmd := &cobra.Command{
Use: "minutes",
Short: "AI 听记 / 会议纪要",
Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理,以及修改标题和纪要内容。`,
Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理、语音备忘查询,以及修改标题和纪要内容。`,
RunE: groupRunE,
}
minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, uploadCmd, permissionCmd, tagCmd)
minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, audioMemoCmd, uploadCmd, permissionCmd, tagCmd)
return minutesCmd
}
@@ -0,0 +1,709 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func executeMarkdownDiff(t *testing.T, args ...string) error {
t.Helper()
testseam.Protect(t, &os.Args)
os.Args = append([]string{"dws", "markdown"}, args...)
root := newMarkdownCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageMarkdownDiffCommand(t *testing.T) {
t.Run("oversized local file", func(t *testing.T) {
testseam.Swap(t, &maxDiffFileSize, int64(8))
big := filepath.Join(t.TempDir(), "big.md")
if err := os.WriteFile(big, []byte("0123456789"), 0o644); err != nil {
t.Fatal(err)
}
if err := checkFileSize(big); err == nil {
t.Fatal("expected oversized local file")
}
})
t.Run("download helpers and ensure type", func(t *testing.T) {
testseam.Protect(t, &diffDownloadLimited)
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("left\n"), nil
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://example.test/a","headers":{"X":"1"}}`},
}})
if _, err := downloadRemoteContent(context.Background(), "fid", 0); err != nil {
t.Fatalf("download latest: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://example.test/a"}`},
}})
if _, err := downloadRemoteContent(context.Background(), "nid", 3); err != nil {
t.Fatalf("download version: %v", err)
}
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return nil, errors.New("dl boom")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://example.test/a"}`},
}})
if _, err := downloadRemoteContent(context.Background(), "fid", 0); err == nil {
t.Fatal("expected download limited error")
}
for _, tc := range []struct {
ext string
ok bool
}{
{"md", true}, {"markdown", true}, {"", true},
{"adoc", false}, {"axls", false}, {"amind", false}, {"adraw", false}, {"pdf", false},
} {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: fmt.Sprintf(`{"result":{"name":"x","extension":%q}}`, tc.ext)},
}})
err := ensureMarkdownDiffType(context.Background(), "n1")
if tc.ok && err != nil {
t.Fatalf("ext %q: %v", tc.ext, err)
}
if !tc.ok && err == nil {
t.Fatalf("ext %q: expected type error", tc.ext)
}
}
for _, ext := range []string{"adoc", "axls", "amind", "adraw", "other"} {
if describeDingTalkDocType(ext) == "" {
t.Fatalf("describe %q empty", ext)
}
}
})
t.Run("defaultDiffDownloadLimited http paths", func(t *testing.T) {
okSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "4")
_, _ = w.Write([]byte("data"))
}))
t.Cleanup(okSrv.Close)
got, err := defaultDiffDownloadLimited(context.Background(), okSrv.URL, map[string]string{"X-Test": "1"})
if err != nil || string(got) != "data" {
t.Fatalf("ok download: %v %q", err, got)
}
badStatus := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("nope"))
}))
t.Cleanup(badStatus.Close)
if _, err := defaultDiffDownloadLimited(context.Background(), badStatus.URL, nil); err == nil {
t.Fatal("expected non-200")
}
testseam.Swap(t, &maxDiffFileSize, int64(3))
tooBigHeader := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "100")
_, _ = w.Write([]byte("xxxx"))
}))
t.Cleanup(tooBigHeader.Close)
if _, err := defaultDiffDownloadLimited(context.Background(), tooBigHeader.URL, nil); err == nil {
t.Fatal("expected content-length guard")
}
chunkedBig := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
// No Content-Length: force LimitReader path to observe oversize body.
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "no hijack", 500)
return
}
conn, bufrw, err := hj.Hijack()
if err != nil {
return
}
defer conn.Close()
payload := strings.Repeat("x", 16)
_, _ = bufrw.WriteString("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n")
_, _ = bufrw.WriteString(fmt.Sprintf("%x\r\n%s\r\n0\r\n\r\n", len(payload), payload))
_ = bufrw.Flush()
}))
t.Cleanup(chunkedBig.Close)
if _, err := defaultDiffDownloadLimited(context.Background(), chunkedBig.URL, nil); err == nil {
t.Fatal("expected body size guard")
}
readFail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hj, ok := w.(http.Hijacker)
if !ok {
return
}
conn, bufrw, err := hj.Hijack()
if err != nil {
return
}
_, _ = bufrw.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n")
_ = bufrw.Flush()
_ = conn.Close() // truncate body → ReadAll error
}))
t.Cleanup(readFail.Close)
maxDiffFileSize = 1000
if _, err := defaultDiffDownloadLimited(context.Background(), readFail.URL, nil); err == nil {
t.Fatal("expected read error")
}
if _, err := defaultDiffDownloadLimited(context.Background(), "http://%\x00", nil); err == nil {
t.Fatal("expected bad url")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := defaultDiffDownloadLimited(ctx, okSrv.URL, nil); err == nil {
t.Fatal("expected canceled ctx")
}
})
t.Run("computeUnifiedDiff deletes", func(t *testing.T) {
text, add, del, hunks, changed := computeUnifiedDiff("a\nb\n", "a\n", 2)
if !changed || del < 1 || hunks < 1 || add != 0 || text == "" {
t.Fatalf("delete-only diff: changed=%v add=%d del=%d hunks=%d", changed, add, del, hunks)
}
})
t.Run("validation and dry-run modes", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executeMarkdownDiff(t, "diff"); err == nil {
t.Fatal("expected missing node")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "0"); err == nil {
t.Fatal("expected version>0")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version2", "0"); err == nil {
t.Fatal("expected version2>0")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--context", "-1"); err == nil {
t.Fatal("expected context>=0")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", "a.md", "--version2", "2"); err == nil {
t.Fatal("expected file/version2 mutex")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1"); err == nil {
t.Fatal("expected remote needs version")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "2"); err != nil {
t.Fatalf("dry-run remote: %v", err)
}
if err := executeMarkdownDiff(t, "diff", "--url", "n1", "--file", "local.md"); err != nil {
t.Fatalf("dry-run local latest: %v", err)
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "2", "--file", "local.md"); err != nil {
t.Fatalf("dry-run local version: %v", err)
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version2", "3"); err != nil {
t.Fatalf("dry-run version2-only: %v", err)
}
})
t.Run("remote vs local and remote vs remote execute", func(t *testing.T) {
testseam.Protect(t, &diffDownloadLimited)
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("alpha\n"), nil
}
local := filepath.Join(t.TempDir(), "right.md")
if err := os.WriteFile(local, []byte("beta\n"), 0o644); err != nil {
t.Fatal(err)
}
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
}})
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", local); err != nil {
t.Fatalf("local json: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{format: "text", steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{text: `{"resourceUrl":"https://example.test/r"}`},
}})
diffDownloadLimited = func(_ context.Context, url string, _ map[string]string) ([]byte, error) {
if strings.Contains(url, "/r") {
return []byte("alpha\n"), nil
}
return []byte("gamma\n"), nil
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err != nil {
t.Fatalf("remote text changed: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{format: "text", steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{text: `{"resourceUrl":"https://example.test/r"}`},
}})
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("same\n"), nil
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err != nil {
t.Fatalf("remote text unchanged: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":"adoc"}`},
}})
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1"); err == nil {
t.Fatal("expected type guard")
}
testseam.Protect(t, &maxDiffFileSize)
maxDiffFileSize = 2
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
}})
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", local); err == nil {
t.Fatal("expected local size fail")
}
maxDiffFileSize = 10 * 1024 * 1024
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
}})
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("x\n"), nil
}
missingLocal := filepath.Join(t.TempDir(), "missing.md")
// pass size check by writing then removing after checkFileSize... actually RunE checks size first then reads.
// Create file for size check, then make ReadFile fail via directory path.
dirAsFile := t.TempDir()
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", dirAsFile); err == nil {
t.Fatal("expected read local dir failure")
}
_ = missingLocal
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{err: errors.New("right fail")},
}})
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("x\n"), nil
}
// second download uses version tool — make parse fail on second call via empty resource
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{text: `{}`},
}})
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil {
t.Fatal("expected right download parse fail")
}
})
t.Run("json marshal and compute timeout", func(t *testing.T) {
testseam.Protect(t, &diffDownloadLimited)
testseam.Protect(t, &diffJSONMarshalIndent)
testseam.Protect(t, &runMarkdownUnifiedDiff)
testseam.Protect(t, &diffComputeTimeout)
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("a\n"), nil
}
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{text: `{"resourceUrl":"https://example.test/r"}`},
}})
diffJSONMarshalIndent = func(any, string, string) ([]byte, error) {
return nil, errors.New("marshal boom")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil || !strings.Contains(err.Error(), "JSON") {
t.Fatalf("expected marshal err, got %v", err)
}
diffJSONMarshalIndent = json.MarshalIndent
runMarkdownUnifiedDiff = func(string, string, int) (string, int, int, int, bool) {
time.Sleep(50 * time.Millisecond)
return "", 0, 0, 0, false
}
diffComputeTimeout = time.Millisecond
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{text: `{"resourceUrl":"https://example.test/r"}`},
}})
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil || !strings.Contains(err.Error(), "超时") {
t.Fatalf("expected timeout, got %v", err)
}
})
t.Run("fetchFileInfo extension field", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":".MD","name":"x.md"}`},
}})
info := fetchFileInfo(context.Background(), "n")
if info.extension != "md" || info.name != "x.md" {
t.Fatalf("info=%+v", info)
}
})
}
func TestCrossPlatformCoverageMailExportShareAndAtomicWrite(t *testing.T) {
t.Run("message export dry-run and execute", func(t *testing.T) {
cwd := t.TempDir()
t.Chdir(cwd)
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "named"); err != nil {
t.Fatalf("export dry-run: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1"); err != nil {
t.Fatalf("export dry-run default name: %v", err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"result":{"message":{"subject":"Hello/World"}}}`},
{text: `{"result":{"emlContent":"From: a\r\n\r\nbody"}}`},
}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1"); err != nil {
t.Fatalf("export: %v", err)
}
if _, err := os.Stat("Hello_World.eml"); err != nil {
t.Fatalf("missing eml: %v", err)
}
// exist without overwrite
caller2 := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"emlContent":"x"}`},
}}
installScriptedCaller(t, caller2)
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "Hello_World"); err == nil {
t.Fatal("expected exist error")
}
caller3 := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"emlContent":"y"}`},
}}
installScriptedCaller(t, caller3)
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "Hello_World", "--overwrite"); err != nil {
t.Fatalf("overwrite: %v", err)
}
// subject fallback to message id when missing
caller4 := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"result":{}}`},
{text: `{"emlContent":"z"}`},
}}
installScriptedCaller(t, caller4)
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "msg-fallback"); err != nil {
t.Fatalf("fallback name: %v", err)
}
})
t.Run("share-to-chat paths", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executePR868Command(t, newMailCommand(),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err != nil {
t.Fatalf("share dry-run: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executePR868Command(t, newMailCommand(),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1"); err != nil {
t.Fatalf("share dry-run no users: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{`}}})
if err := executePR868Command(t, newMailCommand(),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err == nil {
t.Fatal("expected parse error")
}
// Stdin "yes" satisfies deferred ConfirmSafety without leaf --yes,
// so skipConfirm=false and the server-sign reconfirm branch runs.
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"result":{"sign":"sig","riskMessage":"careful"}}`},
}})
if err := executeMailShare(t, strings.NewReader("yes\n"),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err == nil || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("expected server reconfirm, got %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"sign":"sig"}`},
}})
if err := executeMailShare(t, strings.NewReader("yes\n"),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err == nil || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("expected reconfirm without riskMessage, got %v", err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"sign":"sig","riskMessage":"careful"}`},
{text: `{"ok":true}`},
}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newMailCommand(),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err != nil {
t.Fatalf("share with yes: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"ok":true}`}}})
if err := executePR868Command(t, newMailCommand(),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err != nil {
t.Fatalf("json success path: %v", err)
}
})
t.Run("calendar-event missing folder id", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
if err := executePR868Command(t, newMailCommand(),
"calendar-event", "list",
"--email", "u@c.com",
"--start", "2026-07-01T00:00:00Z",
"--end", "2026-07-31T23:59:59Z",
); err == nil {
t.Fatal("expected missing id/folder-id")
}
})
t.Run("sanitize and atomic write", func(t *testing.T) {
if got := sanitizeMailFilename(" a/b\\c\x00 "); got != "a_b_c" {
t.Fatalf("sanitize=%q", got)
}
if got := sanitizeMailFilename(" "); got != "mail" {
t.Fatalf("empty sanitize=%q", got)
}
dir := t.TempDir()
path := filepath.Join(dir, "out.eml")
if err := atomicWriteFile(path, []byte("one"), 0o600, false); err != nil {
t.Fatal(err)
}
if err := atomicWriteFile(path, []byte("two"), 0o600, false); err == nil {
t.Fatal("expected exist without overwrite")
}
if err := atomicWriteFile(path, []byte("two"), 0o600, true); err != nil {
t.Fatal(err)
}
if err := atomicWriteFile(filepath.Join(dir, "no-such", "x.eml"), []byte("x"), 0o600, false); err == nil {
t.Fatal("expected create temp fail")
}
testseam.Protect(t, &atomicCreateTemp)
testseam.Protect(t, &atomicRemove)
testseam.Protect(t, &atomicRename)
atomicRemove = func(string) error { return nil }
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
return &atomicFakeTemp{chmodErr: errors.New("chmod boom")}, nil
}
if err := atomicWriteFile(filepath.Join(dir, "c.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "权限") {
t.Fatalf("chmod: %v", err)
}
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
return &atomicFakeTemp{writeErr: errors.New("write boom")}, nil
}
if err := atomicWriteFile(filepath.Join(dir, "w.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "写入") {
t.Fatalf("write: %v", err)
}
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
return &atomicFakeTemp{syncErr: errors.New("sync boom")}, nil
}
if err := atomicWriteFile(filepath.Join(dir, "s.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "同步") {
t.Fatalf("sync: %v", err)
}
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
return &atomicFakeTemp{closeErr: errors.New("close boom")}, nil
}
if err := atomicWriteFile(filepath.Join(dir, "cl.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "关闭") {
t.Fatalf("close: %v", err)
}
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
return &atomicFakeTemp{}, nil
}
atomicRename = func(string, string) error { return errors.New("rename boom") }
if err := atomicWriteFile(filepath.Join(dir, "r.eml"), []byte("x"), 0o600, true); err == nil || !strings.Contains(err.Error(), "重命名") {
t.Fatalf("rename: %v", err)
}
})
t.Run("export save non-exist failure", func(t *testing.T) {
cwd := t.TempDir()
t.Chdir(cwd)
testseam.Swap(t, &atomicCreateTemp, func(string, string) (atomicTempFile, error) {
return nil, errors.New("nospc")
})
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"emlContent":"body"}`},
}})
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "x"); err == nil || !strings.Contains(err.Error(), "保存文件失败") {
t.Fatalf("expected save failure, got %v", err)
}
})
}
func executeMailShare(t *testing.T, in io.Reader, args ...string) error {
t.Helper()
testseam.Protect(t, &os.Args)
os.Args = append([]string{"dws", "mail"}, args...)
root := newMailCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetIn(in)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageDiffEngineEdges(t *testing.T) {
// Expand matches backward + negative context clamp + missing newline marker.
old := []byte("a\nb\nc\nd\ne")
neu := []byte("a\nb\nC\nd\ne\n")
diff := UnifiedDiff("o", old, "n", neu, -3)
if len(diff) == 0 {
t.Fatal("expected diff with negative context")
}
diff2 := UnifiedDiff("o", []byte("same"), "n", []byte("same\nextra\n"), 2)
if !strings.Contains(string(diff2), "No newline at end of file") && !strings.Contains(string(diff2), "+extra") {
t.Fatalf("unexpected diff2=%q", diff2)
}
_ = UnifiedDiff("o", []byte("1\n2\n3\n4\n5\n6\n7\n"), "n", []byte("1\n2\n3\nX\n5\n6\n7\n"), 10)
_ = UnifiedDiff("o", []byte("only-old\n"), "n", []byte("only-new\n"), 0)
// Non-unique "common" lines before a unique anchor → backward expand (L90).
_ = UnifiedDiff("o", []byte("OLD\ncommon\ncommon\nUNIQUE\n"), "n", []byte("NEW\ncommon\ncommon\nUNIQUE\n"), 1)
_ = UnifiedDiff("o", []byte("OLD\ncommon\ncommon\nUNIQUE\ntail\n"), "n", []byte("NEW\ncommon\ncommon\nUNIQUE\ntail\nextra\n"), 2)
// Large context after an early emitted chunk → chunk.x/y clamps + new-chunk prefix.
_ = UnifiedDiff("o", []byte("A\nU\nZ\n"), "n", []byte("B\nU\nZ\n"), 10)
_ = UnifiedDiff("o", []byte("U\nZ\n"), "n", []byte("B\nU\nZ\n"), 10)
_ = UnifiedDiff("o", []byte("A\nU\nZ\n"), "n", []byte("U\nZ\n"), 10)
_ = UnifiedDiff("o", []byte("A\n\n\nU\nrest\n"), "n", []byte("B\n\n\nU\nrest\nmore\n"), 5)
if nonNeg(-3) != 0 || nonNeg(0) != 0 || nonNeg(4) != 4 {
t.Fatalf("nonNeg")
}
}
func TestCrossPlatformCoverageDriveLatestRemaining(t *testing.T) {
items := []map[string]any{
{"name": "a", "sortTime": int64(5), "rel_path": "same", "fileId": "2", "type": "file"},
{"name": "b", "sortTime": int64(5), "rel_path": "same", "fileId": "1", "type": "file"},
{"name": "c", "sortTime": int64(5), "rel_path": "z", "fileId": "3", "type": "file"},
{"nodeType": "Folder", "name": "folder"},
}
got := applyDriveListLatest(items, 10)
if len(got) != 3 {
t.Fatalf("len=%d", len(got))
}
if n, err := json.Number("99").Int64(); err != nil || n != 99 {
t.Fatal(err)
}
if ms, ok := toMillis(json.Number("99")); !ok || ms != 99 {
t.Fatalf("json.Number millis=%v %v", ms, ok)
}
if _, ok := toMillis(json.Number("-1")); ok {
t.Fatal("negative json.Number")
}
if _, ok := toMillis(struct{}{}); ok {
t.Fatal("unknown type")
}
// pagination + quiet=false progress + shortfall hint with pattern
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"name":"a.md","fileId":"1","type":"file"},{"name":"x.bin","fileId":"2","type":"file"},{"fileId":"3","type":"file"}],"nextToken":"n1"}`},
{text: `{"items":[{"name":"b.md","fileName":"b.md","fileId":"4","type":"file"}],"nextToken":""}`},
}}
installScriptedCaller(t, caller)
testseam.Protect(t, &os.Args)
os.Args = []string{"dws", "drive", "list"}
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(context.Background())
if err := runDriveListLatest(cmd, map[string]any{"spaceId": "s"}, "folder", 5, "*.md", false); err != nil {
t.Fatalf("latest paginate: %v", err)
}
// nil context uses Background
caller2 := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"name":"a.md","fileId":"1","type":"file"}],"nextToken":""}`},
}}
installScriptedCaller(t, caller2)
cmd2 := &cobra.Command{Use: "list"}
if err := runDriveListLatest(cmd2, nil, "", 1, "", true); err != nil {
t.Fatalf("nil ctx: %v", err)
}
}
func TestCrossPlatformCoverageDriveListLatestBadFolder(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newDriveCommand(), "list", "--latest", "2", "--folder", "12345"); err == nil {
t.Fatal("expected numeric folder rejection")
}
}
func TestCrossPlatformCoverageDriveDepthLatestTruncatedAndSortTime(t *testing.T) {
useDriveDepthArgs(t)
var sb strings.Builder
sb.WriteString(`{"items":[`)
for i := 0; i < driveDepthMaxItems; i++ {
if i > 0 {
sb.WriteString(",")
}
fmt.Fprintf(&sb, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifiedTime":%d}`, i, i, 1000+i)
}
sb.WriteString(`]}`)
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 2)
if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_TRUNCATED") {
t.Fatalf("err=%v, want LATEST_SCAN_TRUNCATED", err)
}
_ = out
}
func TestCrossPlatformCoverageWhiteboardSeams(t *testing.T) {
testseam.Swap(t, &whiteboardJSONMarshal, func(any) ([]byte, error) { return nil, errors.New("boom") })
if buildWhiteboardCardJSONML("b", "w") != "" {
t.Fatal("expected empty on marshal fail")
}
testseam.Swap(t, &prepareWhiteboardCard, func(*cobra.Command, string) (string, error) {
return "", errors.New("bad template")
})
installScriptedCaller(t, &scriptedToolCaller{})
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1", "--yes"); err == nil || !strings.Contains(err.Error(), "白板卡片模板") {
t.Fatalf("expected prepare fail, got %v", err)
}
testseam.Protect(t, &os.Args)
os.Args = []string{"dws", "doc", "whiteboard"}
// nil entry in blocks list
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"blocks":[null,{"blockId":"b","jsonml":"[\"card\",{\"metadata\":{\"id\":\"w\"}}]"}]}`},
}})
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err != nil {
t.Fatalf("nil entry skip: %v", err)
}
}
@@ -0,0 +1,488 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func executePR868Command(t *testing.T, root *cobra.Command, args ...string) error {
t.Helper()
oldArgs := os.Args
os.Args = append([]string{"dws", root.Name()}, args...)
t.Cleanup(func() { os.Args = oldArgs })
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageMinutesNewSurfaces(t *testing.T) {
t.Run("hot-word delete dry-run", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete", "--words", "钉钉,OKR"); err != nil {
t.Fatalf("hot-word delete dry-run: %v", err)
}
})
t.Run("hot-word delete missing words", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete"); err == nil {
t.Fatal("expected missing --words error")
}
})
t.Run("hot-word delete executes", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete", "--words", "钉钉"); err != nil {
t.Fatalf("hot-word delete: %v", err)
}
if caller.tool != "delete_personal_hotword" {
t.Fatalf("tool=%q", caller.tool)
}
})
t.Run("permission apply dry-run", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task-1", "--policy", "4"); err != nil {
t.Fatalf("permission apply dry-run: %v", err)
}
})
t.Run("permission apply alias uuid", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--uuid", "task-2", "--policy", "2"); err != nil {
t.Fatalf("permission apply alias: %v", err)
}
if caller.tool != "apply_minutes_permission" {
t.Fatalf("tool=%q", caller.tool)
}
if caller.args["taskUuid"] != "task-2" || caller.args["policyId"] != float64(2) {
t.Fatalf("args=%#v", caller.args)
}
})
t.Run("permission apply invalid policy", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task", "--policy", "1"); err == nil {
t.Fatal("expected invalid policy")
}
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task", "--policy", "x"); err == nil {
t.Fatal("expected non-numeric policy")
}
})
t.Run("permission apply missing flags", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--policy", "4"); err == nil {
t.Fatal("expected missing id")
}
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task"); err == nil {
t.Fatal("expected missing policy")
}
})
t.Run("audio-memo list default", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"items":[]}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list"); err != nil {
t.Fatalf("audio-memo list: %v", err)
}
if caller.tool != "list_audio_memos" {
t.Fatalf("tool=%q", caller.tool)
}
if caller.args["pageSize"] != float64(200) {
t.Fatalf("pageSize=%#v", caller.args["pageSize"])
}
})
t.Run("audio-memo list with range and cursor", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"items":[]}`}}}
installScriptedCaller(t, caller)
err := executePR868Command(t, newMinutesCommand(),
"audio-memo", "list",
"--max", "10",
"--cursor", "1740000000000",
"--start", "2026-01-01T00:00:00+08:00",
"--end", "2026-07-21T23:59:59+08:00",
)
if err != nil {
t.Fatalf("audio-memo list ranged: %v", err)
}
if caller.args["cursor"] != float64(1740000000000) {
t.Fatalf("cursor=%#v", caller.args["cursor"])
}
start, _ := caller.args["startTime"].(string)
end, _ := caller.args["endTime"].(string)
if !strings.Contains(start, "2026-01-01") || !strings.Contains(end, "2026-07-21") {
t.Fatalf("start/end=%q/%q", start, end)
}
})
t.Run("audio-memo list validation", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--max", "0"); err == nil {
t.Fatal("expected max validation")
}
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--max", "1001"); err == nil {
t.Fatal("expected max upper bound")
}
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--cursor", "-1"); err == nil {
t.Fatal("expected cursor validation")
}
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list",
"--start", "2026-07-21T00:00:00+08:00", "--end", "2026-01-01T00:00:00+08:00"); err == nil {
t.Fatal("expected reversed range error")
}
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--start", "bad"); err == nil {
t.Fatal("expected bad start")
}
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--end", "bad"); err == nil {
t.Fatal("expected bad end")
}
})
}
func TestCrossPlatformCoverageDocExportGetTaskIDAlias(t *testing.T) {
// Existing primary --job-id must remain usable.
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"status":"SUCCESS","downloadUrl":"https://x"}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newDocCommand(), "export", "get", "--job-id", "job-legacy"); err != nil {
t.Fatalf("export get --job-id: %v", err)
}
if caller.tool != "query_export_job" || caller.args["jobId"] != "job-legacy" {
t.Fatalf("tool/args=%q %#v", caller.tool, caller.args)
}
// Add-only synonym --task-id.
caller2 := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"status":"PROCESSING"}`}}}
installScriptedCaller(t, caller2)
if err := executePR868Command(t, newDocCommand(), "export", "get", "--task-id", "job-123"); err != nil {
t.Fatalf("export get --task-id: %v", err)
}
if caller2.args["jobId"] != "job-123" {
t.Fatalf("task-id args=%#v", caller2.args)
}
}
func TestCrossPlatformCoverageDriveAliasAndDownloadVersion(t *testing.T) {
t.Run("permission list max-results", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newDriveCommand(), "permission", "list", "--node", "n1", "--max-results", "10"); err != nil {
t.Fatalf("permission list: %v", err)
}
if caller.args["maxResults"] != 10 {
t.Fatalf("maxResults=%#v", caller.args["maxResults"])
}
})
t.Run("cover file-id alias", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newDriveCommand(), "cover", "--file-id", "n1"); err != nil {
t.Fatalf("cover --file-id: %v", err)
}
})
t.Run("revert doc-id alias dry-run", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newDriveCommand(), "revert", "--doc-id", "n1", "--version", "3"); err != nil {
t.Fatalf("revert --doc-id: %v", err)
}
})
t.Run("star add url alias", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newDriveCommand(), "star", "add", "--url", "https://example/n1"); err != nil {
t.Fatalf("star add --url: %v", err)
}
})
t.Run("download --version routes", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
out := filepath.Join(t.TempDir(), "out.pdf")
if err := executePR868Command(t, newDriveCommand(), "download", "--node", "n1", "--version", "3", "--output", out); err != nil {
t.Fatalf("download --version: %v", err)
}
})
}
func TestCrossPlatformCoverageMailCalendarEventFolderID(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
err := executePR868Command(t, newMailCommand(),
"calendar-event", "list",
"--email", "a@b.com",
"--folder-id", "cal-1",
"--start", "2026-07-01T00:00:00Z",
"--end", "2026-07-31T23:59:59Z",
)
if err != nil {
t.Fatalf("calendar-event list --folder-id: %v", err)
}
}
func TestCrossPlatformCoverageUnifiedDiffEngine(t *testing.T) {
if got := UnifiedDiff("a", []byte("same\n"), "b", []byte("same\n"), 3); got != nil {
t.Fatalf("identical should be nil, got %q", got)
}
old := []byte("one\ntwo\nthree\n")
neu := []byte("one\nTWO\nthree\nfour\n")
diff := UnifiedDiff("old.txt", old, "new.txt", neu, 2)
if len(diff) == 0 || !strings.Contains(string(diff), "@@") {
t.Fatalf("expected hunk diff, got %q", diff)
}
_ = UnifiedDiff("o", []byte("a\nb\n"), "n", []byte("a\nc\n"), 0)
_ = UnifiedDiff("o", []byte("{\n\n}\n"), "n", []byte("{\n x\n}\n"), 3)
_ = UnifiedDiff("o", []byte("alpha\n"), "n", []byte("beta\n"), 1)
}
func TestCrossPlatformCoverageDriveLatestHelpers(t *testing.T) {
cmd := &cobra.Command{Use: "list"}
cmd.Flags().Int("latest", 0, "")
cmd.Flags().String("order-by", "", "")
cmd.Flags().String("order", "", "")
cmd.Flags().Int("limit", 0, "")
cmd.Flags().Int("max", 0, "")
cmd.Flags().String("cursor", "", "")
cmd.Flags().String("next-token", "", "")
if err := validateDriveListLatest(cmd, 0); err == nil {
t.Fatal("expected latest lower bound")
}
if err := validateDriveListLatest(cmd, 51); err == nil {
t.Fatal("expected latest upper bound")
}
_ = cmd.ParseFlags([]string{"--order-by=name"})
if err := validateDriveListLatest(cmd, 3); err == nil {
t.Fatal("expected exclusive order-by")
}
cmd2 := &cobra.Command{Use: "list"}
cmd2.Flags().Int("latest", 0, "")
cmd2.Flags().String("order-by", "", "")
cmd2.Flags().String("order", "", "")
cmd2.Flags().Int("limit", 0, "")
cmd2.Flags().Int("max", 0, "")
cmd2.Flags().String("cursor", "", "")
cmd2.Flags().String("next-token", "", "")
_ = cmd2.ParseFlags([]string{"--limit=10"})
if err := validateDriveListLatest(cmd2, 3); err == nil {
t.Fatal("expected exclusive limit")
}
cmd3 := &cobra.Command{Use: "list"}
cmd3.Flags().Int("latest", 0, "")
cmd3.Flags().String("order-by", "", "")
cmd3.Flags().String("order", "", "")
cmd3.Flags().Int("limit", 0, "")
cmd3.Flags().Int("max", 0, "")
cmd3.Flags().String("cursor", "", "")
cmd3.Flags().String("next-token", "", "")
_ = cmd3.ParseFlags([]string{"--cursor=tok"})
if err := validateDriveListLatest(cmd3, 3); err == nil {
t.Fatal("expected exclusive cursor")
}
if err := validateDriveListLatest(cmd3, 3); err == nil {
// already failed above
}
_ = validateDriveListLatest(&cobra.Command{Use: "x"}, 3) // no exclusive flags
items := []map[string]any{
{"name": "b.txt", "sortTime": int64(1), "rel_path": "b", "fileId": "2", "type": "file"},
{"name": "a.txt", "sortTime": int64(2), "rel_path": "a", "fileId": "1", "type": "file"},
{"name": "dir", "sortTime": int64(9), "type": "folder", "dentryType": "folder"},
}
got := applyDriveListLatest(items, 1)
if len(got) != 1 {
t.Fatalf("latest len=%d", len(got))
}
stripDriveDepthDecorations(got)
if _, ok := got[0]["sortTime"]; ok {
t.Fatal("sortTime should be stripped")
}
if ms, ok := driveItemModifiedMillis(map[string]any{"modifiedTime": float64(123)}); !ok || ms != 123 {
t.Fatalf("float millis=%v %v", ms, ok)
}
if ms, ok := toMillis("2026-01-02T03:04:05Z"); !ok || ms <= 0 {
t.Fatalf("rfc3339 millis=%v %v", ms, ok)
}
if _, ok := toMillis(""); ok {
t.Fatal("empty string should fail")
}
if _, ok := toMillis(float64(-1)); ok {
t.Fatal("negative float should fail")
}
if ms, ok := toMillis("42"); !ok || ms != 42 {
t.Fatalf("int string=%v %v", ms, ok)
}
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
listCmd := &cobra.Command{Use: "list"}
listCmd.SetContext(context.Background())
if err := runDriveListLatest(listCmd, map[string]any{"spaceId": "s"}, "folder", 2, "*.md", true); err != nil {
t.Fatalf("dry-run latest: %v", err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{{
text: `{"items":[{"name":"a.md","fileId":"1","type":"file"},{"name":"skip.bin","fileId":"2","type":"file"},{"name":"dir","type":"FOLDER"}],"nextToken":""}`,
}}}
installScriptedCaller(t, caller)
oldArgs := os.Args
os.Args = []string{"dws", "drive", "list"}
t.Cleanup(func() { os.Args = oldArgs })
listCmd2 := &cobra.Command{Use: "list"}
listCmd2.SetContext(context.Background())
if err := runDriveListLatest(listCmd2, nil, "", 5, "*.md", false); err != nil {
t.Fatalf("latest scan: %v", err)
}
}
func executeWhiteboardCommand(t *testing.T, args ...string) error {
t.Helper()
oldArgs := os.Args
os.Args = append([]string{"dws", "doc", "whiteboard"}, args...)
t.Cleanup(func() { os.Args = oldArgs })
root := newDocWhiteboardCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageWhiteboardInsertPaths(t *testing.T) {
t.Run("dry-run plan", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1"); err != nil {
t.Fatalf("whiteboard insert dry-run: %v", err)
}
})
t.Run("insert with placement and verify", func(t *testing.T) {
caller := &pr868FlexibleCaller{whiteboardID: "wb-persisted"}
testseam.Protect(t, &deps)
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
testseam.Swap(t, &whiteboardSleep, func(time.Duration) {})
if err := executeWhiteboardCommand(t,
"insert", "--node", "doc-1", "--ref-block", "ref", "--where", "before", "--index", "2", "--yes"); err != nil {
t.Fatalf("whiteboard insert: %v", err)
}
})
t.Run("soft success when verify empty", func(t *testing.T) {
// Empty blocks are eventual-consistency pending, not hard query failure.
caller := &pr868FlexibleCaller{emptyVerify: true}
testseam.Protect(t, &deps)
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
testseam.Swap(t, &whiteboardSleep, func(time.Duration) {})
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1", "--yes"); err != nil {
t.Fatalf("pending-block soft success: %v", err)
}
})
t.Run("helpers", func(t *testing.T) {
oldArgs := os.Args
os.Args = []string{"dws", "doc", "whiteboard", "insert"}
t.Cleanup(func() { os.Args = oldArgs })
if buildWhiteboardCardJSONML("b", "w") == "" {
t.Fatal("empty jsonml")
}
if extractWhiteboardID(nil) != "" {
t.Fatal("nil attrs")
}
if extractWhiteboardID(map[string]any{"metadata": map[string]any{"id": "x"}}) != "x" {
t.Fatal("extract id")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: context.Canceled}}})
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
t.Fatal("expected query error")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{`}}})
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
t.Fatal("expected parse error")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"other","jsonml":"[]"}]}`}}})
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
t.Fatal("expected missing block")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"b","jsonml":"not-json"}]}`}}})
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
t.Fatal("expected jsonml parse error")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{"blocks":[{"blockId":"b","jsonml":"[\"card\"]"}]}}`}}})
if _, err := queryWhiteboardCardAttrs(context.Background(), "n", "b"); err == nil {
t.Fatal("expected missing attrs")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"b","jsonml":"[\"card\",null]"}]}`}}})
if _, err := queryWhiteboardCardAttrs(context.Background(), "n", "b"); err == nil {
t.Fatal("expected nil attrs")
}
})
}
type pr868FlexibleCaller struct {
whiteboardID string
emptyVerify bool
format string
dry bool
}
func (c *pr868FlexibleCaller) CallTool(_ context.Context, _, tool string, args map[string]any) (*edition.ToolResult, error) {
if tool == "insert_document_block" {
return textToolResult(`{}`), nil
}
if tool == "list_document_blocks" {
if c.emptyVerify {
return textToolResult(`{"blocks":[]}`), nil
}
blockID, _ := args["blockId"].(string)
payload := `{"blocks":[{"blockId":"` + blockID + `","jsonml":"[\"card\",{\"metadata\":{\"type\":\"hetu/draw\",\"id\":\"` + c.whiteboardID + `\"}},[\"span\",{},[\"span\",{},\"\"]]]"}]}`
return textToolResult(payload), nil
}
return textToolResult(`{}`), nil
}
func (c *pr868FlexibleCaller) Format() string { return c.format }
func (c *pr868FlexibleCaller) DryRun() bool { return c.dry }
func (*pr868FlexibleCaller) Fields() string { return "" }
func (*pr868FlexibleCaller) JQ() string { return "" }
func TestCrossPlatformCoverageMarkdownDiffHelpers(t *testing.T) {
if formatFileSize(100) == "" || formatFileSize(2048) == "" || formatFileSize(2*1024*1024) == "" {
t.Fatal("formatFileSize")
}
small := filepath.Join(t.TempDir(), "ok.md")
if err := os.WriteFile(small, []byte("hi\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := checkFileSize(small); err != nil {
t.Fatalf("checkFileSize small: %v", err)
}
if err := checkFileSize(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Fatal("expected missing file")
}
diff, add, _, hunks, changed := computeUnifiedDiff("a\n", "a\nb\n", 2)
if !changed || add < 1 || hunks < 1 || diff == "" {
t.Fatalf("computeUnifiedDiff=%v %d %d %q", changed, add, hunks, diff)
}
if _, _, _, _, ch := computeUnifiedDiff("x\n", "x\n", 2); ch {
t.Fatal("identical should be unchanged")
}
right := filepath.Join(t.TempDir(), "right.md")
if err := os.WriteFile(right, []byte("hi\nthere\n"), 0o644); err != nil {
t.Fatal(err)
}
if describeDingTalkDocType("adoc") == "" {
t.Fatal("describeDingTalkDocType")
}
_ = right
}
@@ -141,18 +141,23 @@ func TestCrossPlatformCoverageProductCommandExamplesAreExecutableContracts(t *te
previousStdin := os.Stdin
previousPut := httpPutFile
previousGet := httpGetFile
previousWhiteboardSleep := whiteboardSleep
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
os.Stdin = previousStdin
httpPutFile = previousPut
httpGetFile = previousGet
whiteboardSleep = previousWhiteboardSleep
})
caller := &productExampleCaller{}
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
// Product examples execute real RunE paths; whiteboard insert retries must
// not burn the suite timeout on real sleep (race CI uses a 12m package cap).
whiteboardSleep = func(time.Duration) {}
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
httpGetFile = func(_ context.Context, _ string, _ map[string]string, destPath string) error {
if destPath == "" {