Compare commits

...
Author SHA1 Message Date
修雨 238f4256d3 ci: harden Gitee mirror synchronization
Serialize Gitee mirror runs, mirror tag events without touching main, and align Gitee release tags before uploading assets.
2026-07-07 20:48:40 +08:00
修雨 b83e6dc239 release: prepare 1.0.48 stable 2026-07-07 20:48:39 +08:00
修雨 a842560d71 ci: harden gitee mirror synchronization 2026-07-07 20:15:50 +08:00
27 changed files with 811 additions and 188 deletions
+12
View File
@@ -16,6 +16,10 @@ on:
- cron: '0 18 * * *'
workflow_dispatch:
concurrency:
group: gitee-code-mirror
cancel-in-progress: false
jobs:
mirror:
runs-on: ubuntu-latest
@@ -36,6 +40,14 @@ jobs:
run: |
set -eu
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
git fetch --force --tags origin "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
git push --force "$REMOTE" "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
echo "✅ 已镜像 tag ${GITHUB_REF_NAME} 到 Gitee ${GITEE_REPO}"
exit 0
fi
# 取到 main 与所有 tag(落到 origin/* 与本地 tags,避免推当前分支引用冲突)
git fetch --force --tags origin 'refs/heads/main:refs/remotes/origin/main'
+1
View File
@@ -84,6 +84,7 @@ jobs:
env:
VERSION: ${{ github.ref_name }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
- name: Setup Node.js
@@ -46,4 +46,5 @@ jobs:
env:
VERSION: ${{ inputs.version }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
+20 -7
View File
@@ -6,15 +6,28 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### Delivery Beta
## [1.0.48] - 2026-07-07
- **Remove-discovery delivery seal** — pre-release validation line for the static-endpoint delivery branch. It removes dynamic service discovery, keeps legacy CLI compatibility aliases, syncs the open command/help/skill surface with the dws-wukong baseline, and includes the `dev connect` default-yolo behavior plus the beta upgrade track.
- **Official beta packages stay out of normal `dws upgrade`** — publish the sealed beta as an upstream GitHub pre-release after merge, using a low pre-release tag such as `v0.0.48-beta.1`. Old clients' plain `dws upgrade` / `dws upgrade --check` follows GitHub `releases/latest`, so it does not install pre-releases. New clients' plain `dws upgrade` stays on the stable track, and the low numeric version prevents the sealed beta from being treated as newer than `v1.0.47`. Testers install this sealed beta only by specifying the exact tag.
- **Pinned curl install for the official beta package after the upstream pre-release exists**:
This release promotes the sealed **remove-discovery delivery** from the beta line to the stable `v1.0.48` package. It removes dynamic service discovery from the open-edition runtime, keeps legacy CLI compatibility aliases, syncs the open command/help/skill surface with the dws-wukong baseline, and includes the `dev connect` default-yolo behavior on the stable upgrade track.
```sh
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | DWS_VERSION=v0.0.48-beta.1 sh
```
### Changed
- **Remove-discovery delivery is now formal/stable** — the beta validation line is ready to cut as `v1.0.48`; normal stable channels (`dws upgrade`, GitHub `releases/latest`, install scripts, and npm `latest`) should receive this release after the official tag is published.
- **Static endpoint runtime sealed for stable delivery** — the open edition no longer depends on dynamic service discovery at runtime, while preserving legacy command compatibility aliases and the synced help/skill surface from the beta.
- **`contact label` is restored as real wukong-compatible functionality** — `dws contact label list/get/list-members` now call `get_org_labels`, `search_label_by_name`, and `get_label_members_by_labelId`; `contact role` remains an alias, and the common top-level compatibility entries (`contact search/find/list/get/self/me/whoami/get-self`) now dispatch to real user/dept/label tools where unambiguous.
- **Skill docs match the sealed command surface** — contact docs again describe the real `contact label` three-step role lookup flow; video-conference start/invite/share flows remain explicitly unsupported and point users to the DingTalk client.
### Fixed
- **`calendar event list --dry-run` no longer executes the real list call** — the sorted event-list wrapper now respects dry-run and prints the `list_calendar_events` preview instead of calling the backend.
- **`chat file upload` is downlined** — the hidden compatibility entry now returns a clear downline message and never calls `chat/upload_conversation_file_by_url`; the supported file path remains `chat message send --msg-type file --file-path`.
- **Optional plugin version validation no longer pollutes every command** — incompatible local plugins such as conference are skipped at debug level during command-tree construction instead of printing a WARN on unrelated commands.
- **PR #45 review follow-ups are folded into the release** — doc version rollback pagination now unwraps nested result/content/data envelopes for `nextCursor`, mail helper scripts handle `{result:{emailAccounts:[...]}}`, and the generated attendance `.xlsx` fixture is removed from the skill scripts.
### Tests
- **Command-surface regression tests** — root-command tests now cover real `contact label`/`role` dry-runs, hidden top-level contact compatibility entries, `chat file upload` downline behavior, and `calendar event list --dry-run`.
- **Release hygiene tests** — skill markdown policy still blocks unsupported conference routes, plugin loader tests assert optional validation failures stay quiet at WARN level, and doc version cursor extraction has nested-envelope coverage.
## [1.0.47] - 2026-07-05
+203
View File
@@ -15,6 +15,8 @@ package app
import (
"bytes"
"io"
"os"
"strings"
"testing"
@@ -67,6 +69,172 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
mustFindCommand(t, root, "conference", "meeting", "reserve")
}
func TestRootKeepsContactWukongCompatibilityCommands(t *testing.T) {
root := NewRootCommand()
label := mustFindCommand(t, root, "contact", "label")
if label.Hidden {
t.Fatal("contact label should be visible as a real command group")
}
if !containsString(label.Aliases, "role") {
t.Fatal("contact label missing role alias")
}
mustFindCommand(t, root, "contact", "label", "get")
mustFindCommand(t, root, "contact", "label", "list")
mustFindCommand(t, root, "contact", "label", "list-members")
mustFindCommand(t, root, "contact", "label", "find")
mustFindCommand(t, root, "contact", "label", "search")
mustFindCommand(t, root, "contact", "label", "info")
mustFindCommand(t, root, "contact", "label", "detail")
mustFindCommand(t, root, "contact", "label", "list-all")
getSelf := mustFindCommand(t, root, "contact", "user", "get-self")
for _, alias := range []string{"self", "me", "whoami", "current"} {
if !containsString(getSelf.Aliases, alias) {
t.Fatalf("contact user get-self missing alias %q", alias)
}
}
for _, tc := range []struct {
name string
args []string
want []string
}{
{
name: "label list",
args: []string{"--dry-run", "contact", "label", "list"},
want: []string{"get_org_labels"},
},
{
name: "label get",
args: []string{"--dry-run", "contact", "label", "get", "--names", "admin,finance"},
want: []string{"search_label_by_name", "labelNames", "admin", "finance"},
},
{
name: "label members",
args: []string{"--dry-run", "contact", "label", "list-members", "--id", "123"},
want: []string{"get_label_members_by_labelId", "labelId", "123"},
},
{
name: "role shim",
args: []string{"--dry-run", "contact", "role", "list"},
want: []string{"get_org_labels"},
},
{
name: "label fuzzy shim",
args: []string{"--dry-run", "contact", "label", "find", "--names", "admin"},
want: []string{"search_label_by_name", "labelNames", "admin"},
},
{
name: "label detail shim",
args: []string{"--dry-run", "contact", "label", "detail", "--id", "123"},
want: []string{"get_label_members_by_labelId", "labelId", "123"},
},
{
name: "contact search shim",
args: []string{"--dry-run", "contact", "search", "--query", "admin"},
want: []string{"search_contact_by_key_word", "keyword", "admin"},
},
{
name: "contact find shim",
args: []string{"--dry-run", "contact", "find", "--query", "admin"},
want: []string{"search_contact_by_key_word", "keyword", "admin"},
},
{
name: "contact list defaults to label list",
args: []string{"--dry-run", "contact", "list"},
want: []string{"get_org_labels"},
},
{
name: "contact list department members",
args: []string{"--dry-run", "contact", "list", "--depts", "1"},
want: []string{"get_dept_members_by_deptId", "deptIds", "1"},
},
{
name: "contact get user details",
args: []string{"--dry-run", "contact", "get", "--ids", "user1"},
want: []string{"get_user_info_by_user_ids", "user_id_list", "user1"},
},
{
name: "contact get label by name",
args: []string{"--dry-run", "contact", "get", "--names", "admin"},
want: []string{"search_label_by_name", "labelNames", "admin"},
},
{
name: "contact self shim",
args: []string{"--dry-run", "contact", "self"},
want: []string{"get_current_user_profile"},
},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := executeRootCaptureStdout(t, tc.args)
if err != nil {
t.Fatalf("Execute(%v) error = %v\n%s", tc.args, err, got)
}
for _, want := range tc.want {
if !strings.Contains(got, want) {
t.Fatalf("Execute(%v) output missing %q:\n%s", tc.args, want, got)
}
}
})
}
}
func TestChatFileUploadDownlinedButMessageFileSendStays(t *testing.T) {
root := NewRootCommand()
fileCmd := mustFindCommand(t, root, "chat", "file")
if !fileCmd.Hidden {
t.Fatal("chat file should be hidden after upload_conversation_file_by_url downline")
}
upload := mustFindCommand(t, root, "chat", "file", "upload")
if !upload.Hidden {
t.Fatal("chat file upload should be hidden after downline")
}
for _, flag := range []string{"group", "url", "file", "file-name"} {
if upload.Flags().Lookup(flag) == nil {
t.Fatalf("chat file upload missing compatibility flag --%s", flag)
}
}
send := mustFindCommand(t, root, "chat", "message", "send")
for _, flag := range []string{"msg-type", "file-path"} {
if send.Flags().Lookup(flag) == nil {
t.Fatalf("chat message send missing --%s", flag)
}
}
got, err := executeRootCaptureStdout(t, []string{
"chat", "file", "upload",
"--group", "cid",
"--url", "https://example.com/report.pdf",
"--file-name", "report.pdf",
})
if err == nil {
t.Fatalf("chat file upload error = nil, want downline error\n%s", got)
}
got = got + "\n" + err.Error()
for _, want := range []string{"已下线", "upload_conversation_file_by_url", "chat message send --msg-type file --file-path"} {
if !strings.Contains(got, want) {
t.Fatalf("chat file upload output missing %q:\n%s", want, got)
}
}
}
func TestCalendarEventListDryRunPreviewsOnly(t *testing.T) {
got, err := executeRootCaptureStdout(t, []string{
"--dry-run", "calendar", "event", "list",
"--start", "2026-07-07T00:00:00+08:00",
"--end", "2026-07-07T01:00:00+08:00",
})
if err != nil {
t.Fatalf("calendar event list --dry-run error = %v\n%s", err, got)
}
for _, want := range []string{"list_calendar_events", "startTime", "endTime"} {
if !strings.Contains(got, want) {
t.Fatalf("calendar dry-run output missing %q:\n%s", want, got)
}
}
}
func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
root := NewRootCommand()
@@ -161,3 +329,38 @@ func mustFindCommand(t *testing.T, root *cobra.Command, path ...string) *cobra.C
}
return cmd
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func executeRootCaptureStdout(t *testing.T, args []string) (string, error) {
t.Helper()
oldStdout := os.Stdout
readPipe, writePipe, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe error = %v", err)
}
os.Stdout = writePipe
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs(args)
execErr := cmd.Execute()
_ = writePipe.Close()
os.Stdout = oldStdout
captured, readErr := io.ReadAll(readPipe)
if readErr != nil {
t.Fatalf("read stdout pipe error = %v", readErr)
}
return out.String() + string(captured), execErr
}
+4
View File
@@ -1899,6 +1899,10 @@ func callFilteredBusyStatus(cmd *cobra.Command, toolArgs map[string]any) error {
}
func callSortedCalendarEvents(cmd *cobra.Command, toolName string, toolArgs map[string]any) error {
if deps.Caller.DryRun() {
return callMCPTool(toolName, toolArgs)
}
raw, err := callMCPToolReturnText(cmd.Context(), toolName, toolArgs)
if err != nil {
return err
+14 -63
View File
@@ -2682,73 +2682,24 @@ func newChatCommand() *cobra.Command {
// ── file 子命令(会话文件上传,不暴露 spaceId)───────────────
chatFileCmd := &cobra.Command{Use: "file", Short: "会话文件上传", RunE: groupRunE}
chatFileCmd := &cobra.Command{
Use: "file",
Short: "会话文件上传(已下线)",
Hidden: true,
RunE: groupRunE,
}
chatFileUploadCmd := &cobra.Command{
Use: "upload",
Short: "上传本地文件或 URL 文件到会话文件空间",
Long: `上传文件到指定会话关联的文件空间,CLI 和服务端会自动完成会话空间解析,调用方无需获取或传递 spaceId。
Use: "upload",
Short: "上传本地文件或 URL 文件到会话文件空间(已下线)",
Hidden: true,
Long: `chat file upload 已下线,不再调用 chat/upload_conversation_file_by_url。
本地文件:一个命令内部完成获取上传链接、HTTP PUT 文件上传和提交。
URL 文件:uploadConversationFileByUrl,由服务端拉取并上传。`,
Example: ` dws chat file upload --group <openConversationId> --file ./report.pdf --format json
dws chat file upload --user <userId> --file ./report.pdf --format json
dws chat file upload --open-dingtalk-id <openDingTalkId> --url https://example.com/report.pdf --file-name report.pdf --format json`,
发送本地文件消息请改用 chat message send --msg-type file --file-path;该路径仍然可用,CLI 内部会完成本地文件上传和消息发送。`,
Example: ` dws chat message send --group <openConversationId> --msg-type file --file-path ./report.pdf --format json
dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file-path ./report.pdf --format json`,
RunE: func(cmd *cobra.Command, args []string) error {
targetArgs, err := buildConversationTargetArgs(cmd)
if err != nil {
return err
}
filePath, _ := cmd.Flags().GetString("file")
fileURL, _ := cmd.Flags().GetString("url")
if filePath == "" && fileURL == "" {
return fmt.Errorf("--file or --url is required")
}
if filePath != "" && fileURL != "" {
return fmt.Errorf("--file and --url are mutually exclusive")
}
fileName, _ := cmd.Flags().GetString("file-name")
md5Value, _ := cmd.Flags().GetString("md5")
uuid, _ := cmd.Flags().GetString("uuid")
if fileURL != "" {
if fileName == "" {
fileName = inferFilename(fileURL)
}
toolArgs := targetArgs
toolArgs["fileUrl"] = fileURL
toolArgs["fileName"] = fileName
if md5Value != "" {
toolArgs["md5"] = md5Value
}
if uuid != "" {
toolArgs["uuid"] = uuid
}
return callMCPToolOnServer("chat", "upload_conversation_file_by_url", toolArgs)
}
meta, err := buildConversationLocalFileMeta(filePath, fileName, md5Value)
if err != nil {
return err
}
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "上传本地文件到会话文件空间")
deps.Out.PrintKeyValue("文件", meta.LocalPath)
deps.Out.PrintKeyValue("名称", meta.FileName)
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", meta.FileSize))
deps.Out.PrintKeyValue("MD5", meta.MD5)
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
commitText, err := uploadConversationLocalFile(ctx, targetArgs, meta, uuid)
if err != nil {
return err
}
return printMCPText(commitText)
return fmt.Errorf("chat file upload 已下线;chat/upload_conversation_file_by_url 当前不可用。发送本地文件请改用: dws chat message send --msg-type file --file-path <本地路径>")
},
}
chatFileUploadCmd.Flags().String("group", "", "群聊 openConversationId(群聊时使用)")
+231 -51
View File
@@ -67,6 +67,15 @@ func contactFirstSetFlagName(cmd *cobra.Command, names ...string) string {
return ""
}
func contactAnyFlagChanged(cmd *cobra.Command, names ...string) bool {
for _, n := range names {
if f := cmd.Flag(n); f != nil && f.Changed {
return true
}
}
return false
}
// contactParseInt64WithAliases 先在主 flag 与全部别名中找出用户实际传入的值(空则报 missing),
// 再走根部门占位符警告 + int64 解析,避免用户传别名时 RunE 读不到。
// 报错文案中使用用户实际输入的 flag 名(比如用户传 --ids me,错误里显示 --ids 而不是主 flag --id),
@@ -215,36 +224,65 @@ func newContactCommand() *cobra.Command {
// ── label 角色 ──────────────────────────────────────────────────
contactLabelCmd := &cobra.Command{
Use: "label",
Short: "角色查询",
Hidden: true,
Long: `角色查询后端工具已下线,保留该命令仅用于兼容旧指令。
Use: "label",
Aliases: []string{"role"},
Short: "角色查询",
Long: `角色查询:获取企业所有角色列表、根据角色名称查询角色ID、根据角色ID查询角色下的成员。
请改用 aisearch person 按职责维度找人,例如:
dws aisearch person --keyword "管理员" --dimension duty`,
【何时用哪个命令】
- 获取企业所有角色列表 → contact label list
- 根据角色名称查询角色ID → contact label get
- 根据角色ID查询角色下的成员 → contact label list-members
【典型场景:查询某类角色的人员(如主管、管理员、财务等)】
1. contact label list → 获取企业全部角色列表
2. 从返回结果中匹配目标角色名称及 labelId
3. contact label list-members --id <labelId> → 获取该角色下的成员`,
RunE: groupRunE,
}
contactLabelUnavailable := func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("contact label 角色查询能力当前不可用:后端工具已下线\n hint: 请改用 dws aisearch person --keyword <角色或职责> --dimension duty")
runContactLabelList := func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
return fmt.Errorf("contact label list 不接受位置参数: %s", strings.Join(args, " "))
}
return callMCPTool("get_org_labels", map[string]any{})
}
runContactLabelGet := func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlagWithAliases(cmd, "names", "name", "query", "keyword"); err != nil {
return err
}
raw := flagOrFallback(cmd, "names", "name", "query", "keyword")
return callMCPTool("search_label_by_name", map[string]any{
"labelNames": parseCSVValues(raw),
})
}
runContactLabelMembers := func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlagWithAliases(cmd, "id", "label-id", "role-id"); err != nil {
return err
}
return callMCPTool("get_label_members_by_labelId", map[string]any{
"labelId": flagOrFallback(cmd, "id", "label-id", "role-id"),
})
}
contactLabelGetCmd := &cobra.Command{
Use: "get",
Short: "根据角色名称查询角色",
Hidden: true,
Long: `contact label 后端工具已下线。请改用 aisearch person --dimension duty 按职责维度找人。`,
Use: "get",
Short: "根据角色名称查询角色",
Long: `根据角色名称精确匹配查询角色信息(角色ID、名称等)。支持同时查询多个角色名称,逗号分隔。无需分页。
注意:精确匹配可能无结果(如用户输入"管理员"但企业只有"主管理员"和"子管理员"),
此时应降级使用 label list 获取全部角色列表,从中模糊匹配包含关键词的角色。`,
Example: ` dws contact label get --names "管理员"
dws contact label get --names "管理员,财务"`,
RunE: contactLabelUnavailable,
RunE: runContactLabelGet,
}
contactLabelListMembersCmd := &cobra.Command{
Use: "list-members",
Short: "查询角色下的成员",
Hidden: true,
Long: `contact label 后端工具已下线。请改用 aisearch person --dimension duty 按职责维度找人。`,
Long: `根据角色ID查询该角色下的成员列表。`,
Example: ` dws contact label list-members --id 12345 # 查询 labelId: dws contact label get --names "角色名"`,
RunE: contactLabelUnavailable,
RunE: runContactLabelMembers,
}
contactLabelGetCmd.Flags().String("names", "", "角色名称,逗号分隔 (必填)")
@@ -262,12 +300,17 @@ func newContactCommand() *cobra.Command {
_ = contactLabelListMembersCmd.Flags().MarkHidden("role-id")
contactLabelListAllCmd := &cobra.Command{
Use: "list",
Short: "获取企业所有角色列表",
Hidden: true,
Long: `contact label 后端工具已下线。请改用 aisearch person --dimension duty 按职责维度找人。`,
Use: "list",
Short: "获取企业所有角色列表",
Long: `获取当前企业的所有角色(标签)列表,返回角色ID、角色名称等信息。无需参数。
用于不知道准确角色名称时,先列出全部角色,再根据需要选择目标角色查询成员。
【典型场景】
- 用户说"企业所有主管/查所有管理员/财务人员有哪些"
→ 先 label list 浏览全部角色,匹配目标角色后 label list-members 获取成员`,
Example: ` dws contact label list`,
RunE: contactLabelUnavailable,
RunE: runContactLabelList,
}
contactLabelCmd.AddCommand(contactLabelListAllCmd, contactLabelGetCmd, contactLabelListMembersCmd)
@@ -567,13 +610,163 @@ contact user profile fields 获取可用字段列表。
relationCmd.AddCommand(contactRelationListMyFollowingsCmd)
root.AddCommand(userCmd, contactDeptCmd, contactLabelCmd, relationCmd)
// hint: dws contact search → dws contact user search / dept search
//
// 使用本地 wrapper contactHintSubCmd,在 cmdutil.HintSubCmd 基础上开启 DisableFlagParsing:
// 这样 `dws contact get --user-id X` 这样带未知 flag 的误输入也会进入 RunE,
// 输出 `hint: use: dws contact user get --ids <用户ID>` 等正向引导,
// 而不是被 cobra 在 flag 解析阶段拦截在 `unknown flag: --user-id`。
// 类似做法见 calendar.go 的 calendarInfoHintSubCmd。
addQueryFlags := func(cmd *cobra.Command) {
cmd.Flags().String("query", "", "搜索关键词 (必填)")
cmd.Flags().String("keyword", "", "--query 的别名")
cmd.Flags().String("name", "", "--query 的别名")
_ = cmd.Flags().MarkHidden("keyword")
_ = cmd.Flags().MarkHidden("name")
}
addUserIDFlags := func(cmd *cobra.Command) {
cmd.Flags().String("ids", "", "用户 ID 列表")
cmd.Flags().String("user-id", "", "--ids 的别名")
cmd.Flags().String("user-ids", "", "--ids 的别名")
cmd.Flags().String("userid", "", "--ids 的别名(全小写)")
_ = cmd.Flags().MarkHidden("user-id")
_ = cmd.Flags().MarkHidden("user-ids")
_ = cmd.Flags().MarkHidden("userid")
}
addLabelNameFlags := func(cmd *cobra.Command) {
cmd.Flags().String("names", "", "角色名称,逗号分隔")
cmd.Flags().String("name", "", "--names 的别名")
cmd.Flags().String("query", "", "--names 的别名")
cmd.Flags().String("keyword", "", "--names 的别名")
_ = cmd.Flags().MarkHidden("name")
_ = cmd.Flags().MarkHidden("query")
_ = cmd.Flags().MarkHidden("keyword")
}
addLabelIDFlags := func(cmd *cobra.Command) {
cmd.Flags().String("id", "", "角色 ID")
cmd.Flags().String("label-id", "", "--id 的别名")
cmd.Flags().String("role-id", "", "--id 的别名")
_ = cmd.Flags().MarkHidden("label-id")
_ = cmd.Flags().MarkHidden("role-id")
}
addDeptIDFlags := func(cmd *cobra.Command) {
cmd.Flags().String("dept", "", "部门 ID")
cmd.Flags().String("id", "", "--dept 的别名")
cmd.Flags().String("dept-id", "", "--dept 的别名")
cmd.Flags().String("dept-ids", "", "--dept 的别名")
_ = cmd.Flags().MarkHidden("id")
_ = cmd.Flags().MarkHidden("dept-id")
_ = cmd.Flags().MarkHidden("dept-ids")
}
runContactUserGet := func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlagWithAliases(cmd, contactUserIDFlagKeys[0], contactUserIDFlagKeys[1:]...); err != nil {
return err
}
raw := flagOrFallback(cmd, contactUserIDFlagKeys[0], contactUserIDFlagKeys[1:]...)
for _, part := range parseCSVValues(raw) {
switch strings.ToLower(strings.TrimSpace(part)) {
case "me", "self", "current", "whoami", "i":
return fmt.Errorf("--ids 需要真实的 userId,不接受 %q 这类占位符\n hint: 获取当前用户用: dws contact user get-self", part)
}
}
return callMCPTool("get_user_info_by_user_ids", map[string]any{
"user_id_list": parseCSVValues(raw),
})
}
runContactUserSearch := func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlagWithAliases(cmd, "query", "keyword", "name"); err != nil {
return err
}
return callMCPTool("search_contact_by_key_word", map[string]any{
"keyword": flagOrFallback(cmd, "query", "keyword", "name"),
})
}
contactRootSearchCmd := &cobra.Command{
Use: "search",
Short: "按关键词搜索用户(兼容入口)",
Hidden: true,
RunE: runContactUserSearch,
}
contactRootFindCmd := &cobra.Command{
Use: "find",
Short: "按关键词搜索用户(兼容入口)",
Hidden: true,
RunE: runContactUserSearch,
}
addQueryFlags(contactRootSearchCmd)
addQueryFlags(contactRootFindCmd)
contactRootGetCmd := &cobra.Command{
Use: "get",
Short: "获取用户/部门/角色详情(兼容入口)",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
switch {
case contactAnyFlagChanged(cmd, contactUserIDFlagKeys...):
return runContactUserGet(cmd, args)
case contactAnyFlagChanged(cmd, "dept", "id", "dept-id", "dept-ids"):
deptID, err := contactParseInt64WithAliases(cmd, "dept", "id", "dept-id", "dept-ids")
if err != nil {
return err
}
return callMCPTool("get_dept_info_by_dept_id", map[string]any{"deptId": deptID})
case contactAnyFlagChanged(cmd, "names", "name", "query", "keyword"):
return runContactLabelGet(cmd, args)
case contactAnyFlagChanged(cmd, "label-id", "role-id"):
return runContactLabelMembers(cmd, args)
default:
return fmt.Errorf("contact get 需要指定 --ids <userId>、--dept <deptId>、--names <角色名> 或 --label-id <角色ID>")
}
},
}
addUserIDFlags(contactRootGetCmd)
addDeptIDFlags(contactRootGetCmd)
addLabelNameFlags(contactRootGetCmd)
contactRootGetCmd.Flags().String("label-id", "", "角色 ID")
contactRootGetCmd.Flags().String("role-id", "", "--label-id 的别名")
_ = contactRootGetCmd.Flags().MarkHidden("role-id")
contactRootListCmd := &cobra.Command{
Use: "list",
Short: "列出角色/部门成员/用户详情(兼容入口)",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
switch {
case contactAnyFlagChanged(cmd, "depts", "id", "dept-id", "dept-ids"):
if err := validateRequiredFlagWithAliases(cmd, "depts", "id", "dept-id", "dept-ids"); err != nil {
return err
}
raw := flagOrFallback(cmd, "depts", "id", "dept-id", "dept-ids")
setName := contactFirstSetFlagName(cmd, "depts", "id", "dept-id", "dept-ids")
for _, t := range parseCSVValues(raw) {
if _, ok := contactRootDeptLikeTokens[strings.ToLower(strings.TrimSpace(t))]; ok {
return fmt.Errorf("flag --%s 包含非法占位符 %q;钉钉根部门 deptId=1,请使用 --%s 1", setName, t, setName)
}
}
return callMCPTool("get_dept_members_by_deptId", map[string]any{"deptIds": parseCSVValues(raw)})
case contactAnyFlagChanged(cmd, contactUserIDFlagKeys...):
return runContactUserGet(cmd, args)
case contactAnyFlagChanged(cmd, "names", "name", "query", "keyword"):
return runContactLabelGet(cmd, args)
default:
return runContactLabelList(cmd, args)
}
},
}
contactRootListCmd.Flags().String("depts", "", "部门 ID 列表")
contactRootListCmd.Flags().String("id", "", "--depts 的别名")
contactRootListCmd.Flags().String("dept-id", "", "--depts 的别名")
contactRootListCmd.Flags().String("dept-ids", "", "--depts 的别名")
_ = contactRootListCmd.Flags().MarkHidden("id")
_ = contactRootListCmd.Flags().MarkHidden("dept-id")
_ = contactRootListCmd.Flags().MarkHidden("dept-ids")
addUserIDFlags(contactRootListCmd)
addLabelNameFlags(contactRootListCmd)
for _, use := range []string{"self", "me", "whoami", "get-self", "user-self", "current-user"} {
root.AddCommand(&cobra.Command{
Use: use,
Short: "获取当前用户信息(兼容入口)",
Hidden: true,
RunE: contactUserGetSelfCmd.RunE,
})
}
root.AddCommand(contactRootSearchCmd, contactRootFindCmd, contactRootGetCmd, contactRootListCmd)
contactHintSubCmd := func(use, suggestion string) *cobra.Command {
c := hintSubCmd(use, suggestion)
runHint := c.RunE
@@ -589,24 +782,7 @@ contact user profile fields 获取可用字段列表。
return c
}
root.AddCommand(contactHintSubCmd("search", "use: dws contact user search\n also available: dws contact dept search / dws aisearch person --dimension duty"))
// hint: dws contact find/list/get → 指向正确子命令
root.AddCommand(contactHintSubCmd("find", "use: dws contact user search --query <关键词> or dws contact dept search --query <关键词> or dws aisearch person --keyword <角色或职责> --dimension duty"))
root.AddCommand(contactHintSubCmd("list", "use: dws contact dept list-members --depts <部门ID> or dws contact user get --ids <用户ID>"))
root.AddCommand(contactHintSubCmd("get", "use: dws contact user get --ids <用户ID> or dws contact dept get-info --dept <部门ID>"))
// hint: dws contact self/me/whoami → user get-self(当前用户语义)
root.AddCommand(contactHintSubCmd("self", "use: dws contact user get-self"))
root.AddCommand(contactHintSubCmd("me", "use: dws contact user get-self"))
root.AddCommand(contactHintSubCmd("whoami", "use: dws contact user get-self"))
// 顶层高频误写:get-self / user-self / current-user / department
root.AddCommand(contactHintSubCmd("get-self", "use: dws contact user get-self"))
root.AddCommand(contactHintSubCmd("user-self", "use: dws contact user get-self"))
root.AddCommand(contactHintSubCmd("current-user", "use: dws contact user get-self"))
root.AddCommand(contactHintSubCmd("department", "use: dws contact dept [search|list-members|list-children|get-info]"))
root.AddCommand(contactHintSubCmd("role", "contact label 已下线; use: dws aisearch person --keyword <角色或职责> --dimension duty"))
// hint: dws contact user find/list/info/detail → 指向 user search / user get
userCmd.AddCommand(contactHintSubCmd("find", "use: dws contact user search --query <关键词>"))
@@ -622,13 +798,17 @@ contact user profile fields 获取可用字段列表。
contactDeptCmd.AddCommand(contactHintSubCmd("info", "use: dws contact dept get-info --dept <部门ID>"))
contactDeptCmd.AddCommand(contactHintSubCmd("detail", "use: dws contact dept get-info --dept <部门ID>"))
// hint: dws contact label find/search/info/detail → 指向 aisearch 替代路径。
// dws contact label find/search/info/detail/list-all → 真实兼容入口。
// 注:list 已是真命令(label list),不再注册 hintSubCmd(会与真命令冲突)。
contactLabelCmd.AddCommand(contactHintSubCmd("find", "contact label 已下线; use: dws aisearch person --keyword <角色或职责> --dimension duty"))
contactLabelCmd.AddCommand(contactHintSubCmd("search", "contact label 已下线; use: dws aisearch person --keyword <角色或职责> --dimension duty"))
contactLabelCmd.AddCommand(contactHintSubCmd("info", "contact label 已下线; use: dws aisearch person --keyword <角色或职责> --dimension duty"))
contactLabelCmd.AddCommand(contactHintSubCmd("detail", "contact label 已下线; use: dws aisearch person --keyword <角色或职责> --dimension duty"))
contactLabelCmd.AddCommand(contactHintSubCmd("list-all", "contact label 已下线; use: dws aisearch person --keyword <角色或职责> --dimension duty"))
for _, use := range []string{"find", "search", "info"} {
cmd := &cobra.Command{Use: use, Hidden: true, RunE: runContactLabelGet}
addLabelNameFlags(cmd)
contactLabelCmd.AddCommand(cmd)
}
contactLabelDetailCmd := &cobra.Command{Use: "detail", Hidden: true, RunE: runContactLabelMembers}
addLabelIDFlags(contactLabelDetailCmd)
contactLabelCmd.AddCommand(contactLabelDetailCmd)
contactLabelCmd.AddCommand(&cobra.Command{Use: "list-all", Hidden: true, RunE: runContactLabelList})
// contact 子树统一错误兜底:任何 flag 解析失败均在尾部追加 "See '<CommandPath> --help' for usage."
// 与 docker / kubectl / gh 的 UX 一致。unknown subcommand 由 cobra 自带 Did-You-Mean 处理。
+25 -10
View File
@@ -62,16 +62,31 @@ func docVersionExists(ctx context.Context, nodeID string, version int) (bool, er
// docVersionNextCursor 从 list_doc_versions 响应中提取分页游标;没有下一页时返回 ""。
func docVersionNextCursor(v any) string {
m, ok := v.(map[string]any)
if !ok {
return ""
}
if hasMore, ok := m["hasMore"].(bool); ok && !hasMore {
return ""
}
for _, key := range []string{"nextCursor", "nextToken", "cursor"} {
if s, ok := m[key].(string); ok && s != "" {
return s
switch val := v.(type) {
case map[string]any:
if hasMore, ok := val["hasMore"].(bool); ok && !hasMore {
return ""
}
for _, key := range []string{"nextCursor", "nextToken", "cursor"} {
if s, ok := val[key].(string); ok && s != "" {
return s
}
}
for _, key := range []string{"result", "content", "data"} {
if s := docVersionNextCursor(val[key]); s != "" {
return s
}
}
for _, item := range val {
if s := docVersionNextCursor(item); s != "" {
return s
}
}
case []any:
for _, item := range val {
if s := docVersionNextCursor(item); s != "" {
return s
}
}
}
return ""
+32
View File
@@ -0,0 +1,32 @@
package helpers
import "testing"
func TestDocVersionNextCursorUnwrapsEnvelope(t *testing.T) {
payload := map[string]any{
"result": map[string]any{
"hasMore": true,
"nextCursor": "page-2",
"versions": []any{
map[string]any{"version": float64(1)},
},
},
}
if got := docVersionNextCursor(payload); got != "page-2" {
t.Fatalf("docVersionNextCursor() = %q, want page-2", got)
}
}
func TestDocVersionNextCursorHonorsNestedHasMoreFalse(t *testing.T) {
payload := map[string]any{
"result": map[string]any{
"hasMore": false,
"nextCursor": "stale",
},
}
if got := docVersionNextCursor(payload); got != "" {
t.Fatalf("docVersionNextCursor() = %q, want empty", got)
}
}
+2 -2
View File
@@ -120,13 +120,13 @@ func (l *Loader) loadPlugin(dir string) *Plugin {
manifestPath := filepath.Join(dir, "plugin.json")
manifest, err := ParseManifest(manifestPath)
if err != nil {
slog.Warn("plugin: failed to parse manifest",
slog.Debug("plugin: failed to parse manifest",
"path", manifestPath, "error", err)
return nil
}
if err := manifest.Validate(l.CLIVersion); err != nil {
slog.Warn("plugin: validation failed",
slog.Debug("plugin: validation failed",
"plugin", manifest.Name, "error", err)
return nil
}
+40
View File
@@ -14,9 +14,12 @@
package plugin
import (
"bytes"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -177,6 +180,43 @@ func TestManifestValidate(t *testing.T) {
}
}
func TestLoadAllSuppressesOptionalPluginValidationWarnings(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "user", "conference")
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
content := `{
"name": "conference",
"version": "1.0.0",
"description": "音视频会议",
"type": "user",
"minCLIVersion": "9.0.0",
"mcpServers": {
"conference": {
"type": "streamable-http",
"endpoint": "https://example.com"
}
}
}`
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
var logs bytes.Buffer
oldLogger := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelWarn})))
defer slog.SetDefault(oldLogger)
loader := &Loader{PluginsDir: dir, CLIVersion: "1.0.48"}
if got := loader.LoadAll(); len(got) != 0 {
t.Fatalf("LoadAll loaded incompatible plugin: %#v", got)
}
if strings.Contains(logs.String(), "validation failed") {
t.Fatalf("optional plugin validation should not warn at default level:\n%s", logs.String())
}
}
func TestPluginToServerDescriptors(t *testing.T) {
cliOverlay, _ := json.Marshal(map[string]any{
"id": "conference",
+41 -4
View File
@@ -14,14 +14,15 @@
#
# Required environment (CI secrets):
# GITEE_TOKEN Gitee private access token (scopes: projects)
# GITEE_USER Gitee username for git push authentication
# GITEE_REPO "owner/repo" on Gitee, e.g. DingTalk-Real-AI/dingtalk-workspace-cli
# Optional:
# VERSION release tag (default: git describe)
# DIST_DIR artifacts dir (default: ./dist)
# GITEE_API API base (default: https://gitee.com/api/v5)
#
# Gating: if GITEE_TOKEN / GITEE_REPO are unset, exit 0 with a notice so the
# step can live in release.yml without breaking forks that lack the secret.
# Gating: if GITEE_TOKEN / GITEE_USER / GITEE_REPO are unset, exit 0 with a
# notice so the step can live in release.yml without breaking forks.
set -eu
@@ -30,6 +31,7 @@ GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
missing=""
[ -z "${GITEE_TOKEN:-}" ] && missing="$missing GITEE_TOKEN"
[ -z "${GITEE_USER:-}" ] && missing="$missing GITEE_USER"
[ -z "${GITEE_REPO:-}" ] && missing="$missing GITEE_REPO"
if [ -n "$missing" ]; then
echo "ℹ️ Gitee mirror sync skipped — missing:${missing}"
@@ -41,11 +43,46 @@ VERSION="${VERSION:-$(git describe --tags --always 2>/dev/null || echo dev)}"
OWNER="${GITEE_REPO%%/*}"
NAME="${GITEE_REPO##*/}"
base="${GITEE_API}/repos/${OWNER}/${NAME}"
git_remote="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
public_git_remote="https://gitee.com/${GITEE_REPO}.git"
echo "📦 Mirroring release ${VERSION} → Gitee ${GITEE_REPO}"
# ── Keep the Gitee git tag aligned with the GitHub release tag ───────────────
# Creating a Gitee release with target_commitish=main can silently create a tag
# at the Gitee-localized main commit. Always push the exact GitHub tag first,
# then verify the peeled commit before touching release attachments.
git fetch --force --tags origin "refs/tags/${VERSION}:refs/tags/${VERSION}" >/dev/null 2>&1 || true
target_commit="$(git rev-parse "${VERSION}^{commit}" 2>/dev/null || true)"
[ -n "$target_commit" ] || { echo "❌ Could not resolve local release tag ${VERSION}; fetch tags before syncing." >&2; exit 1; }
gitee_tag_commit() {
git ls-remote "$public_git_remote" "refs/tags/${VERSION}*" 2>/dev/null \
| awk -v tag="refs/tags/${VERSION}" '
$2 == tag "^{}" { peeled=$1 }
$2 == tag { direct=$1 }
END {
if (peeled != "") print peeled;
else if (direct != "") print direct;
}'
}
echo " Syncing Gitee tag ${VERSION} -> ${target_commit}"
git push --force "$git_remote" "refs/tags/${VERSION}:refs/tags/${VERSION}" >/dev/null
gitee_commit=""
for _ in 1 2 3 4 5 6 7 8 9 10 11 12; do
gitee_commit="$(gitee_tag_commit || true)"
[ "$gitee_commit" = "$target_commit" ] && break
sleep 5
done
[ "$gitee_commit" = "$target_commit" ] || {
echo "❌ Gitee tag ${VERSION} is not aligned after push: got ${gitee_commit:-<missing>}, want ${target_commit}" >&2
exit 1
}
echo " Gitee tag ${VERSION} is aligned."
# ── Resolve or create the Gitee release for this tag ──────────────────────────
# Gitee mirror sync brings the git tag over, so the tag should already exist.
rel_json="$(curl -fsSL "${base}/releases/tags/${VERSION}?access_token=${GITEE_TOKEN}" 2>/dev/null || true)"
release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)"
@@ -56,7 +93,7 @@ if [ -z "$release_id" ]; then
-F "tag_name=${VERSION}" \
-F "name=${VERSION}" \
-F "body=Mirror of GitHub release ${VERSION} for China users." \
-F "target_commitish=main" 2>/dev/null || true)"
-F "target_commitish=${target_commit}" 2>/dev/null || true)"
release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)"
fi
[ -n "$release_id" ] || { echo "❌ Could not get/create Gitee release for ${VERSION}. Response: ${rel_json}" >&2; exit 1; }
@@ -1,6 +1,6 @@
# 会议管理(日程与会议室)
> **lite 速查表**含 `start-conference`、`list-today-meetings`、`check-users-busy`(见 [SKILL.md](../../SKILL.md) 中「Lite Recipe 清单」→ [lite-recipes.md](./lite-recipes.md))。列表类操作须遵循 [calendar.md](../products/calendar.md) **「CLI 命令树与黄金路径」**,禁止无子命令的 `dws calendar` 或臆造 `calendar list`(见该文 **「反模式(禁止)」**)。**`schedule-meeting` 不做内联**:须读本文件 **「两准则」「搜房失败硬门禁」** 及下表 **schedule-meeting** 行全文。
> **lite 速查表**含 `list-today-meetings`、`check-users-busy`(见 [SKILL.md](../../SKILL.md) 中「Lite Recipe 清单」→ [lite-recipes.md](./lite-recipes.md));视频会议 `start-conference` 当前 CLI 不支持。列表类操作须遵循 [calendar.md](../products/calendar.md) **「CLI 命令树与黄金路径」**,禁止无子命令的 `dws calendar` 或臆造 `calendar list`(见该文 **「反模式(禁止)」**)。**`schedule-meeting` 不做内联**:须读本文件 **「两准则」「搜房失败硬门禁」** 及下表 **schedule-meeting** 行全文。
> **听记、会后待办、摘要分享** 见 [07-minutes.md](./07-minutes.md)。
## 日程与会议室两准则(强制)
@@ -52,5 +52,5 @@
| Recipe | 行动指南(固定路线) |
| ------------------ | ------------------- |
| schedule-meeting | **见上文「两准则」**、**「搜房失败硬门禁」**。**未给时段且仅说「发起/开个会」**→ 不走本 recipe,走 lite recipe `start-conference`。**未给时段但有预约意图**("安排""约""定"等词):追问具体开始/结束时间。**已有时段后**,按固定顺序执行:1. `dws calendar event create` 建日程;2. 有参会人则 `dws calendar participant add`;3. 再处理会议室。**无明确会议室范围**:可直接 `python scripts/calendar_schedule_meeting.py --title "<主题>" --start "<起始>" --end "<结束>" [--users <userIds>] [--book-room] [--dry-run]`。**有明确范围(某楼/层)**:先 `dws calendar room list-groups`,锁定该地点**最相关的承载 group**;若只有一个地点,`--room-group-id` 应只传这个最相关 group,**不要**把同楼内多个楼层 group 打包传入碰运气。只有用户明确给出多个允许地点时,才把这些 `group-id` 一并传给 `python scripts/calendar_schedule_meeting.py ... --book-room --room-group-id "<id1,id2,...>"`。**用户点名具体会议室**:须手工 `dws calendar room search --start "<ISO>" --end "<ISO>" --group-id <GROUP_ID> [--available] --format json`(**无** `--query`),在 JSON 中匹配名称取 **`rooms[].roomId` 唯一真值** → `dws calendar room add --event <eventId> --rooms <roomId>`;**不得**把用户输入的会议室名当 `roomId`。**一旦连续 2 次空结果 / 任意一次 `roomId invalid`**:**必须回读本节并立即收束判断**;若整园/限定范围内搜尽仍无 roomId 或无空房 → **下一条消息必须直接向用户汇报失败结论**;否则只能向用户确认是否放宽范围/改时间。**禁止**假设 roomId、禁止无 ID 调用 `room add`、禁止用日程详情绕路、禁止继续猜测 Mock/测试环境。细则见「会议室搜索早停」。 |
| schedule-meeting | **见上文「两准则」**、**「搜房失败硬门禁」**。**未给时段且仅说「发起/开个会」**→ 不走本 recipe;当前 CLI 不支持实时视频会议,告知用户请在钉钉客户端操作。**未给时段但有预约意图**("安排""约""定"等词):追问具体开始/结束时间。**已有时段后**,按固定顺序执行:1. `dws calendar event create` 建日程;2. 有参会人则 `dws calendar participant add`;3. 再处理会议室。**无明确会议室范围**:可直接 `python scripts/calendar_schedule_meeting.py --title "<主题>" --start "<起始>" --end "<结束>" [--users <userIds>] [--book-room] [--dry-run]`。**有明确范围(某楼/层)**:先 `dws calendar room list-groups`,锁定该地点**最相关的承载 group**;若只有一个地点,`--room-group-id` 应只传这个最相关 group,**不要**把同楼内多个楼层 group 打包传入碰运气。只有用户明确给出多个允许地点时,才把这些 `group-id` 一并传给 `python scripts/calendar_schedule_meeting.py ... --book-room --room-group-id "<id1,id2,...>"`。**用户点名具体会议室**:须手工 `dws calendar room search --start "<ISO>" --end "<ISO>" --group-id <GROUP_ID> [--available] --format json`(**无** `--query`),在 JSON 中匹配名称取 **`rooms[].roomId` 唯一真值** → `dws calendar room add --event <eventId> --rooms <roomId>`;**不得**把用户输入的会议室名当 `roomId`。**一旦连续 2 次空结果 / 任意一次 `roomId invalid`**:**必须回读本节并立即收束判断**;若整园/限定范围内搜尽仍无 roomId 或无空房 → **下一条消息必须直接向用户汇报失败结论**;否则只能向用户确认是否放宽范围/改时间。**禁止**假设 roomId、禁止无 ID 调用 `room add`、禁止用日程详情绕路、禁止继续猜测 Mock/测试环境。细则见「会议室搜索早停」。 |
| reschedule-meeting | 1. `calendar event list --start "<起始ISO>" --end "<结束ISO>"` → 取 `eventId` 2. `calendar event update --id <eventId> --start "<新起始ISO>" --end "<新结束ISO>"` 更新时间 3. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message send --group <openConversationId> --text "<变更通知>"` 通知变更 |
@@ -40,23 +40,18 @@
### start-conference
> 触发:「发起会议」「开个会」「创建会议」且**没有给出具体时间** → 直接执行,无需追问。
> 当前 CLI 不提供视频会议(conference)发起/入会/会中控制能力。触发「发起会议」「开个会」「创建会议」且**没有给出具体时间**时,不要构造 `conference` 命令;直接告知用户请在钉钉客户端操作。
`conference start [--title "<主题>"]`
- 用户给了主题 → 加 `--title`;没给 → 省略(系统用默认标题)
- 有具体时间(如"明天3点开会")→ 不走此 recipe,走 03-meeting.md 的 `schedule-meeting`
- 有具体时间(如"明天3点开会")或明确预约日程 → 走 03-meeting.md 的 `schedule-meeting`
- 只有实时视频会议诉求 → 说明当前 CLI 不支持,请在钉钉客户端发起
### invite-participant
1. 查人:`contact user search --query "<姓名>"` → `openDingTalkId`、`nick`
2. 获取会议 ID:`conference get-id` → `conferenceId`
3. 邀请:`conference member invite --conference-id <conferenceId> --nicks "<nick>" --open-dingtalk-ids "<openDingTalkId>"`
视频会议邀请入会当前 CLI 不支持,请在钉钉客户端操作。若用户要给已有日程加参会人,走 `calendar participant add`。
### share-screen
- 共享屏幕:`conference share start`
- 停止共享:`conference share stop`
共享屏幕/停止共享属于会中控制,当前 CLI 不支持,请在钉钉客户端操作。
## #4 文档知识
+1 -1
View File
@@ -652,5 +652,5 @@ dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15
## 相关产品
- [conference](./simple.md) — 仅视频会议预约(返回入会链接),不含参会人/会议室管理
- conference 视频会议 — 当前 CLI 不支持发起/预约/邀请入会/会中控制;请在钉钉客户端操作
- [contact](./contact.md) — 搜索同事 userId,用于 attendee add --attendees
+13 -32
View File
@@ -475,41 +475,22 @@ Flags:
- 发送文字 + 文件混合消息时的完整流程:除了将文件以 Markdown 链接内嵌到文字消息中发送一条 md 消息外,还必须额外逐个发送独立的文件消息(--msg-type file),确保接收方可以直接下载原始文件。即:先发一条包含文字和文件链接的 md 消息,再对每个涉及的文件各发一条 --msg-type file 的文件消息
```
### file (会话文件上传)
### file (会话文件上传,已下线)
#### 上传本地文件或 URL 文件到会话文件空间 — 不暴露 spaceId
#### chat file upload 已下线
上传文件到指定会话关联的文件空间。调用方只需要提供会话和文件来源,不需要先调用 conversation-info,也不需要传递 spaceId。若只是发送本地文件,优先使用 `dws chat message send --msg-type file --file-path <本地文件>`。
`dws chat file upload` 已隐藏下线,不再调用 `chat/upload_conversation_file_by_url`,URL 文件服务端代传当前不可用。
发送本地文件消息仍然支持,统一使用:
```
dws chat message send --group <openConversationId> --msg-type file --file-path ./report.pdf --format json
dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file-path ./report.pdf --format json
```
Usage:
dws chat file upload [flags]
Example:
# 本地文件:CLI 会初始化上传、直传文件内容并提交
dws chat file upload --group <openConversationId> --file ./report.pdf --format json
dws chat file upload --user <userId> --file ./report.pdf --format json
dws chat file upload --open-dingtalk-id <openDingTalkId> --file ./report.pdf --format json
# URL 文件:服务端拉取 URL 并上传到会话文件空间
dws chat file upload --group <openConversationId> --url https://example.com/report.pdf --file-name report.pdf --format json
Flags:
--group string 群聊 openConversationId(群聊时使用)
--user string 单聊对方 userId(单聊时使用)
--open-dingtalk-id string 单聊对方 openDingTalkId(单聊时使用)
--file string 本地文件路径(与 --url 二选一)
--url string 远程文件 URL(与 --file 二选一,服务端代传)
--file-name string 文件名(可选,本地文件默认取文件名,URL 默认从 URL 推断)
--md5 string 文件 MD5(可选,本地文件不传时自动计算)
--uuid string 幂等 UUID(可选)
注意:
- --group、--user、--open-dingtalk-id 互斥,必须且只能指定其一
- --file 和 --url 互斥,必须且只能指定其一
- 本地文件由一个命令内部完成:获取上传链接 → CLI 直传文件内容 → 提交上传
- URL 文件走服务端代传:服务端自行解析会话空间并上传到会话文件空间
- 若只是发送本地文件,直接使用 `dws chat message send --msg-type file --file-path <本地文件>`,CLI 会复用同一套上传逻辑
- 发图片/文件优先走 `chat message send --msg-type file --file-path <本地路径>`,无需调用 `chat file upload`;本节只在以下场景使用:(a) URL 文件由服务端代传 (`--url`);(b) 业务需要先拿到下载链接再以 Markdown 形式内嵌到文字消息中
- **文字 + 文件双消息**(仅适用于非图片文件;图片直接 `--msg-type file --file-path` 单条消息即可,不要走双发):先发一条 Markdown 文字消息引用下载链接,再对每个文件各补发一条 `--msg-type file` 文件消息,确保接收方既看到文字说明又能直接下载原始文件
```
- 不要再构造 `dws chat file upload --url ...`,该路径会直接返回下线提示
- 常规发图/发文件/发音视频都走 `chat message send --msg-type file --file-path <本地路径>`
- 若需要“文字 + 文件”双消息,先发送文件消息,再补一条文本或 Markdown 消息说明
#### 查询消息发送状态 — 查询以当前用户身份发送的消息的发送状态
@@ -1572,7 +1553,7 @@ Flags:
- `chat clear-messages` — 清空当前用户视角下指定会话的聊天记录(不影响其他成员)
- `chat group list-all` — 分页拉取当前用户加入的所有群(list-my-groups 仅返回群主/管理员的群)
- `chat group list-join-validations` / `chat group audit-join-validation` — 拉取入群验证记录 / 审批入群验证(通过/拒绝/删除/忽略/拉黑)
- `chat file upload` — 上传本地文件或 URL 文件到会话文件空间(不暴露 spaceId;纯发文件优先用 `chat message send --msg-type file --file-path`)
- `chat file upload` — 已下线;不要调用 `chat/upload_conversation_file_by_url`,本地文件发送改用 `chat message send --msg-type file --file-path`
- `chat group transfer-owner` — 转让群主
- `chat group invite-url` — 获取群邀请链接
- `chat message reply` — 引用回复消息(在群聊中引用某条消息并回复文字)
@@ -1810,7 +1791,7 @@ Flags:
| `chat message search-advanced` | `nextCursor` | 下次 message search-advanced 的 --cursor |
| `chat search-common` | `openConversationId` | message send/list 等的 --group |
| `chat conversation-info` | `newCSpaceIdIM` | drive upload 的 --space-id(发送文件消息前获取共享空间) |
| `chat file upload` | 下载链接字段 | message send 的 Markdown 链接文本(仅文字+非图片文件双发场景;常规发图/发文件用 `--msg-type file --file-path`) |
| `chat file upload` | 无(已下线) | 不要调用;常规发图/发文件用 `chat message send --msg-type file --file-path` |
| `chat message list` | `openMsgId` | message read-status 的 --message-id |
| `chat group-role list` | `openRoleId` | group-role update/remove/set-user/remove-user 的 --role-id |
| `chat message create-text-emotion` | `emotionId` | add-text-emotion 的 --emotion-id |
+13
View File
@@ -55,6 +55,18 @@ def validate_emails(emails_str: str) -> bool:
return True
def unwrap_result(data: Any) -> Any:
while isinstance(data, dict):
for key in ('result', 'content', 'data'):
nested = data.get(key)
if isinstance(nested, (dict, list)):
data = nested
break
else:
return data
return data
def get_my_email(dry_run: bool = False) -> Optional[str]:
data = run_dws([
'mail', 'mailbox', 'list', '--format', 'json',
@@ -63,6 +75,7 @@ def get_my_email(dry_run: bool = False) -> Optional[str]:
return '<MY_EMAIL>'
if not data:
return None
data = unwrap_result(data)
if isinstance(data, dict) and isinstance(data.get('emailAccounts'), list):
accounts = data['emailAccounts']
# 优先企业邮箱(type=ORG),否则取第一个
@@ -39,6 +39,18 @@ def run_dws(
return None
def unwrap_result(data: Any) -> Any:
while isinstance(data, dict):
for key in ('result', 'content', 'data'):
nested = data.get(key)
if isinstance(nested, (dict, list)):
data = nested
break
else:
return data
return data
def get_my_email(dry_run: bool = False) -> Optional[str]:
data = run_dws([
'mail', 'mailbox', 'list', '--format', 'json',
@@ -47,6 +59,7 @@ def get_my_email(dry_run: bool = False) -> Optional[str]:
return '<MY_EMAIL>'
if not data:
return None
data = unwrap_result(data)
if isinstance(data, dict) and isinstance(data.get('emailAccounts'), list):
accounts = data['emailAccounts']
# 优先企业邮箱(type=ORG),否则取第一个
@@ -99,6 +112,7 @@ def main():
print('未查到邮件')
return
data = unwrap_result(data)
messages = (data if isinstance(data, list)
else data.get('items', data.get('messages', [])))
+2 -2
View File
@@ -1,6 +1,6 @@
---
name: dingtalk-calendar
description: 钉钉日历与会议室。Use when 用户说 约会议/查日程/订会议室/查闲忙/加参会人/改期/取消会议/今天的日程/本周日程/共同空闲。Distinct from dingtalk-conference(视频会议发起/预约/邀请入会/会中控制)、dingtalk-minutes(听记)、dingtalk-todo(待办)。命令前缀:dws calendar。
description: 钉钉日历与会议室。Use when 用户说 约会议/查日程/订会议室/查闲忙/加参会人/改期/取消会议/今天的日程/本周日程/共同空闲。视频会议发起/邀请入会/会中控制当前 CLI 不支持,应提示在钉钉客户端操作。Distinct from dingtalk-minutes(听记)、dingtalk-todo(待办)。命令前缀:dws calendar。
cli_version: ">=0.2.14"
metadata:
category: product
@@ -45,7 +45,7 @@ metadata:
## 跨产品协作
- 视频会议发起 / 入会链接 / 邀请入会 / 会中控制 → 切到 `dingtalk-conference`
- 视频会议发起 / 入会链接 / 邀请入会 / 会中控制 → 当前 CLI 不支持,请在钉钉客户端操作;预约日程仍走 `calendar`
- 会后摘要 / 待办 → 切到 `dingtalk-minutes`
- 参会人按人名 → 先用 `dingtalk-aisearch` 解析
@@ -1,6 +1,6 @@
# 会议管理(日程与会议室)
> **lite 速查表**含 `start-conference`、`list-today-meetings`、`check-users-busy`(见 `dws-shared/references/best_practices/_common/lite-recipes.md`)。列表类操作须遵循 [calendar.md](./calendar.md) **「CLI 命令树与黄金路径」**,禁止无子命令的 `dws calendar` 或臆造 `calendar list`(见该文 **「反模式(禁止)」**)。**`schedule-meeting` 不做内联**:须读本文件 **「两准则」「搜房失败硬门禁」** 及下表 **schedule-meeting** 行全文。
> **lite 速查表**含 `list-today-meetings`、`check-users-busy`;视频会议 `start-conference` 当前 CLI 不支持。列表类操作须遵循 [calendar.md](./calendar.md) **「CLI 命令树与黄金路径」**,禁止无子命令的 `dws calendar` 或臆造 `calendar list`(见该文 **「反模式(禁止)」**)。**`schedule-meeting` 不做内联**:须读本文件 **「两准则」「搜房失败硬门禁」** 及下表 **schedule-meeting** 行全文。
> **听记、会后待办、摘要分享** 见 `dingtalk-minutes` sub-skill 的 `references/07-minutes.md`。
## 日程与会议室两准则(强制)
@@ -52,5 +52,5 @@
| Recipe | 行动指南(固定路线) |
| ------------------ | ------------------- |
| schedule-meeting | **见上文「两准则」**、**「搜房失败硬门禁」**。**未给时段且仅说「发起/开个会」**→ 不走本 recipe,走 lite recipe `start-conference`。**未给时段但有预约意图**("安排""约""定"等词):追问具体开始/结束时间。**已有时段后**,按固定顺序执行:1. `dws calendar event create` 建日程;2. 有参会人则 `dws calendar participant add`;3. 再处理会议室。**无明确会议室范围**:可直接 `python scripts/calendar_schedule_meeting.py --title "<主题>" --start "<起始>" --end "<结束>" [--users <userIds>] [--book-room] [--dry-run]`。**有明确范围(某楼/层)**:先 `dws calendar room list-groups`,锁定该地点**最相关的承载 group**;若只有一个地点,`--room-group-id` 应只传这个最相关 group,**不要**把同楼内多个楼层 group 打包传入碰运气。只有用户明确给出多个允许地点时,才把这些 `group-id` 一并传给 `python scripts/calendar_schedule_meeting.py ... --book-room --room-group-id "<id1,id2,...>"`。**用户点名具体会议室**:须手工 `dws calendar room search --start "<ISO>" --end "<ISO>" --group-id <GROUP_ID> [--available] --format json`(**无** `--query`),在 JSON 中匹配名称取 **`rooms[].roomId` 唯一真值** → `dws calendar room add --event <eventId> --rooms <roomId>`;**不得**把用户输入的会议室名当 `roomId`。**一旦连续 2 次空结果 / 任意一次 `roomId invalid`**:**必须回读本节并立即收束判断**;若整园/限定范围内搜尽仍无 roomId 或无空房 → **下一条消息必须直接向用户汇报失败结论**;否则只能向用户确认是否放宽范围/改时间。**禁止**假设 roomId、禁止无 ID 调用 `room add`、禁止用日程详情绕路、禁止继续猜测 Mock/测试环境。细则见「会议室搜索早停」。 |
| schedule-meeting | **见上文「两准则」**、**「搜房失败硬门禁」**。**未给时段且仅说「发起/开个会」**→ 不走本 recipe;当前 CLI 不支持实时视频会议,告知用户请在钉钉客户端操作。**未给时段但有预约意图**("安排""约""定"等词):追问具体开始/结束时间。**已有时段后**,按固定顺序执行:1. `dws calendar event create` 建日程;2. 有参会人则 `dws calendar participant add`;3. 再处理会议室。**无明确会议室范围**:可直接 `python scripts/calendar_schedule_meeting.py --title "<主题>" --start "<起始>" --end "<结束>" [--users <userIds>] [--book-room] [--dry-run]`。**有明确范围(某楼/层)**:先 `dws calendar room list-groups`,锁定该地点**最相关的承载 group**;若只有一个地点,`--room-group-id` 应只传这个最相关 group,**不要**把同楼内多个楼层 group 打包传入碰运气。只有用户明确给出多个允许地点时,才把这些 `group-id` 一并传给 `python scripts/calendar_schedule_meeting.py ... --book-room --room-group-id "<id1,id2,...>"`。**用户点名具体会议室**:须手工 `dws calendar room search --start "<ISO>" --end "<ISO>" --group-id <GROUP_ID> [--available] --format json`(**无** `--query`),在 JSON 中匹配名称取 **`rooms[].roomId` 唯一真值** → `dws calendar room add --event <eventId> --rooms <roomId>`;**不得**把用户输入的会议室名当 `roomId`。**一旦连续 2 次空结果 / 任意一次 `roomId invalid`**:**必须回读本节并立即收束判断**;若整园/限定范围内搜尽仍无 roomId 或无空房 → **下一条消息必须直接向用户汇报失败结论**;否则只能向用户确认是否放宽范围/改时间。**禁止**假设 roomId、禁止无 ID 调用 `room add`、禁止用日程详情绕路、禁止继续猜测 Mock/测试环境。细则见「会议室搜索早停」。 |
| reschedule-meeting | 1. `calendar event list --start "<起始ISO>" --end "<结束ISO>"` → 取 `eventId` 2. `calendar event update --id <eventId> --start "<新起始ISO>" --end "<新结束ISO>"` 更新时间 3. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message send --group <openConversationId> --text "<变更通知>"` 通知变更 |
@@ -541,5 +541,5 @@ dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15
## 相关产品
- [conference](./simple.md) — 仅视频会议预约(返回入会链接),不含参会人/会议室管理
- conference 视频会议 — 当前 CLI 不支持发起/预约/邀请入会/会中控制;请在钉钉客户端操作
- [contact](./contact.md) — 搜索同事 userId,用于 attendee add --attendees
@@ -55,6 +55,18 @@ def validate_emails(emails_str: str) -> bool:
return True
def unwrap_result(data: Any) -> Any:
while isinstance(data, dict):
for key in ('result', 'content', 'data'):
nested = data.get(key)
if isinstance(nested, (dict, list)):
data = nested
break
else:
return data
return data
def get_my_email(dry_run: bool = False) -> Optional[str]:
data = run_dws([
'mail', 'mailbox', 'list', '--format', 'json',
@@ -63,6 +75,16 @@ def get_my_email(dry_run: bool = False) -> Optional[str]:
return '<MY_EMAIL>'
if not data:
return None
data = unwrap_result(data)
if isinstance(data, dict) and isinstance(data.get('emailAccounts'), list):
accounts = data['emailAccounts']
# 优先企业邮箱(type=ORG),否则取第一个
for acc in accounts:
if isinstance(acc, dict) and acc.get('type') == 'ORG' and acc.get('email'):
return acc['email']
if accounts and isinstance(accounts[0], dict):
return accounts[0].get('email')
return None
if isinstance(data, list) and data:
item = data[0]
return (item.get('email') or item.get('address')
@@ -39,6 +39,18 @@ def run_dws(
return None
def unwrap_result(data: Any) -> Any:
while isinstance(data, dict):
for key in ('result', 'content', 'data'):
nested = data.get(key)
if isinstance(nested, (dict, list)):
data = nested
break
else:
return data
return data
def get_my_email(dry_run: bool = False) -> Optional[str]:
data = run_dws([
'mail', 'mailbox', 'list', '--format', 'json',
@@ -47,6 +59,16 @@ def get_my_email(dry_run: bool = False) -> Optional[str]:
return '<MY_EMAIL>'
if not data:
return None
data = unwrap_result(data)
if isinstance(data, dict) and isinstance(data.get('emailAccounts'), list):
accounts = data['emailAccounts']
# 优先企业邮箱(type=ORG),否则取第一个
for acc in accounts:
if isinstance(acc, dict) and acc.get('type') == 'ORG' and acc.get('email'):
return acc['email']
if accounts and isinstance(accounts[0], dict):
return accounts[0].get('email')
return None
if isinstance(data, list) and data:
item = data[0]
return (item.get('email') or item.get('address')
@@ -90,6 +112,7 @@ def main():
print('未查到邮件')
return
data = unwrap_result(data)
messages = (data if isinstance(data, list)
else data.get('items', data.get('messages', [])))
@@ -107,4 +107,4 @@ Args:
## 相关产品
- `dingtalk-calendar` (`references/calendar.md`) — 日历日程管理(含参与者/会议室)
- `dingtalk-conference` (`references/conference.md`) — 视频会议(发起/预约/邀请入会/会中控制)
- 视频会议(conference)— 当前 CLI 不提供对应 skill;发起/预约/邀请入会/会中控制请在钉钉客户端操作
+86
View File
@@ -0,0 +1,86 @@
package unit_test
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestSkillDocsDoNotRecommendRetiredCommands(t *testing.T) {
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller(0) failed")
}
root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
skillsDir := filepath.Join(root, "skills")
retiredCommands := []string{
"chat file upload",
"conference start",
"conference get-id",
"conference member invite",
"conference share",
"dingtalk-conference",
}
allowedContext := []string{
"已下线",
"下线",
"不支持",
"不要",
"无需",
"当前 CLI 不支持",
"兼容提示",
"不可用",
"钉钉客户端",
}
var violations []string
err := filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
if filepath.Ext(path) != ".md" {
return nil
}
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
rel, _ := filepath.Rel(root, path)
for i, line := range strings.Split(string(content), "\n") {
for _, retired := range retiredCommands {
if !strings.Contains(line, retired) {
continue
}
if hasAny(line, allowedContext) {
continue
}
violations = append(violations, fmt.Sprintf("%s:%d recommends retired command %q: %s", rel, i+1, retired, strings.TrimSpace(line)))
}
}
return nil
})
if err != nil {
t.Fatalf("WalkDir() error = %v", err)
}
if len(violations) > 0 {
t.Fatalf("skill docs recommend retired commands:\n%s", strings.Join(violations, "\n"))
}
}
func hasAny(s string, needles []string) bool {
for _, needle := range needles {
if strings.Contains(s, needle) {
return true
}
}
return false
}