diff --git a/.github/eval-allowlist.txt b/.github/eval-allowlist.txt new file mode 100644 index 00000000..8cde985f --- /dev/null +++ b/.github/eval-allowlist.txt @@ -0,0 +1,61 @@ +# /eval 自助触发允许名单 +# +# 名单内的 GitHub 登录名可对【自己创建的 PR】触发 /eval 评测; +# 对任意 PR 触发仍需仓库 write/maintain/admin 权限(维护者背书)。 +# 授权读取的始终是默认分支上的本文件,PR 无法修改自身授权。 +# +# 变更本文件必须走 PR 评审。每行一个 GitHub login,# 开头为注释。 + +aftersss +notable-open +EdgarWang0925 +ayunya +yutongshe +qingyang1014 +caiTriumph +xlb1130 +Anonymity-0 +FuShu-Yang +guimingyue +AlwaysLee +TaoJikun +zengyoulingzyl-stack +liyuan333 +huangyoo +lifeihong +nitonitori +cywan1998 +gangwn +junlonghuo2 +aqruan +Freda0909 +ShawnWhite777 +PeterGuy326 +abucraft +pengzhihan47-star +rainyak8 +gongrongyun +huangyuanzhuo-coder +ybcstudy +bigqy +liwang-ai +meng93 +wxianfeng +Patrick-Star-CN +rossluo28-hz +dxy704330469 +gtezg30062 +Neige-Premaire +zhuoyu20 +avicii-chen +typefield +Haofeng0705 +Huwenjiao +liuzeyang +maoqxxmm +FloralTide +lingyun9833 +dxb121 +C0922 +xiaoji121 +H3java diff --git a/.github/workflows/eval-dispatch.yml b/.github/workflows/eval-dispatch.yml new file mode 100644 index 00000000..70df32e4 --- /dev/null +++ b/.github/workflows/eval-dispatch.yml @@ -0,0 +1,139 @@ +name: PR Eval Dispatch + +# `/eval [sha=] [cases=]` PR 评论 → 触发内网评测流水线,报告由内网 bot 回贴。 +# 本 workflow 只在默认分支上下文运行,不 checkout、不执行 PR 代码。 +# 审核 SHA 规则:评测他人 PR 必须显式携带 sha=(审阅背书凭据,验证 +# 其恰为当前 open head);评测自己创建的 PR 可省略,自动钉住派发时刻 +# 的当前 head(作者自背书,无第三方偷换窗口);内网 CI 另以 +# FETCH_HEAD 校验兜底派发后的变更。 +# 授权两级:仓库 write/maintain/admin 可派发任意 PR;默认分支 +# .github/eval-allowlist.txt 名单内的用户仅可派发自己创建的 PR。 +# 触发通道与凭证全部经 secrets 注入,文件内不出现任何内网信息。 + +on: + issue_comment: + types: + - created + +permissions: {} + +concurrency: + group: eval-dispatch-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + dispatch: + name: Dispatch internal evaluation + if: >- + github.event.issue.pull_request && + startsWith(github.event.comment.body, '/eval') + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + issues: write + pull-requests: read + steps: + - name: Check out default branch tooling + uses: actions/checkout@v4 + + - name: Verify commenter dispatch authorization + env: + GH_TOKEN: ${{ github.token }} + COMMENTER: ${{ github.event.comment.user.login }} + PR_AUTHOR: ${{ github.event.issue.user.login }} + EVAL_ALLOWLIST_PATH: .github/eval-allowlist.txt + run: | + # 不用 --fail:非协作者查权限返回 404 错误体,交由 guard 走名单分支;硬网络错误降级为空对象同样 fail-closed + permission_json="$(curl --silent --show-error \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/collaborators/${COMMENTER}/permission")" || permission_json='{}' + printf '%s' "$permission_json" | python3 scripts/ci/eval_dispatch_guard.py permission + + - name: Parse /eval command + id: parse + continue-on-error: true + env: + COMMENT_BODY: ${{ github.event.comment.body }} + run: python3 scripts/ci/eval_comment_parse.py + + - name: Reply usage on parse failure + if: steps.parse.outcome == 'failure' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.issue.number }} + PARSE_ERROR: ${{ steps.parse.outputs.error }} + run: | + body="❌ /eval 命令解析失败:${PARSE_ERROR}" + jq -n --arg body "$body" '{body: $body}' | curl --fail --silent --show-error \ + -X POST \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + --data @- \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null + exit 1 + + - name: Verify reviewed PR head + id: pr + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.issue.number }} + EXPECTED_PR_NUMBER: ${{ github.event.issue.number }} + REVIEWED_SHA: ${{ steps.parse.outputs.reviewed_sha }} + COMMENTER: ${{ github.event.comment.user.login }} + run: | + pr_json="$(curl --fail --silent --show-error \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}")" + printf '%s' "$pr_json" \ + | python3 scripts/ci/eval_dispatch_guard.py head \ + >> "$GITHUB_OUTPUT" + + - name: Trigger internal evaluation pipeline + env: + EVAL_TRIGGER_TOKEN: ${{ secrets.EVAL_TRIGGER_TOKEN }} + EVAL_TRIGGER_URL: ${{ secrets.EVAL_TRIGGER_URL }} + PR_NUMBER: ${{ github.event.issue.number }} + PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + PRODUCTS: ${{ steps.parse.outputs.products }} + CASES_REF: ${{ steps.parse.outputs.cases_ref }} + run: | + if [ -z "$EVAL_TRIGGER_TOKEN" ] || [ -z "$EVAL_TRIGGER_URL" ]; then + echo "EVAL_TRIGGER_URL / EVAL_TRIGGER_TOKEN not configured; cannot dispatch." >&2 + exit 1 + fi + jq -n \ + --arg pr "$PR_NUMBER" \ + --arg sha "$PR_HEAD_SHA" \ + --arg products "$PRODUCTS" \ + --arg cases "$CASES_REF" \ + '{branch: "main", params: {pr_number: $pr, pr_head_sha: $sha, products: $products, cases_ref: $cases}}' \ + | curl --fail --silent --show-error \ + -X POST \ + -H "private-token: ${EVAL_TRIGGER_TOKEN}" \ + -H "Content-Type: application/json" \ + --data @- \ + "$EVAL_TRIGGER_URL" + echo "Internal evaluation dispatched." + + - name: Acknowledge on PR + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.issue.number }} + PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + PRODUCTS: ${{ steps.parse.outputs.products }} + CASES_REF: ${{ steps.parse.outputs.cases_ref }} + run: | + cases_note="" + if [ -n "$CASES_REF" ]; then + cases_note=",用例版本 \`${CASES_REF}\`" + fi + body="🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。内网评测流水线运行结束后将由 bot 回贴报告(首行为基线对比头条)。" + jq -n --arg body "$body" '{body: $body}' | curl --fail --silent --show-error \ + -X POST \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + --data @- \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null diff --git a/scripts/ci/eval_comment_parse.py b/scripts/ci/eval_comment_parse.py new file mode 100644 index 00000000..24596151 --- /dev/null +++ b/scripts/ci/eval_comment_parse.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""解析 PR 评论中的 `/eval [sha=] [cases=]` 触发命令。 + +供 .github/workflows/eval-dispatch.yml 调用:评论体经环境变量 COMMENT_BODY +传入(避免 argv 注入),解析结果以 GitHub Actions output +(products / cases_ref / reviewed_sha)写出;格式非法时把单行错误写入 +output `error` 并以非零退出。 + +产品集显式必填;`sha=` 在评测他人 PR 时必填(审核背书凭据,由 guard 校验), +评测自己创建的 PR 时可省略(自助模式,自动钉住派发时刻的当前 head); +`cases=` 为用例仓库版本逃生舱(破坏性变更配对验证)。 +""" + +import os +import re +import sys + +PRODUCT_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") +REF_CHARSET_RE = re.compile(r"^[A-Za-z0-9._/-]+$") +SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$") +USAGE = ( + "用法: /eval [,...] [sha=<40位PR-head-SHA>] [cases=];" + "评测他人 PR 时 sha= 必填,自己的 PR 可省略;" + "示例: /eval drive,doc sha=0123456789abcdef0123456789abcdef01234567" +) + + +def _is_valid_cases_ref(ref: str) -> bool: + """等价于 git check-ref-format --allow-onelevel 的结构校验(不执行 git)。 + + 字符白名单之上补齐结构规则:禁止首尾斜杠与连续斜杠、尾部点号、 + 任意位置的 `..`、以点开头或以 .lock 结尾的路径分量;另禁止 `-` + 开头,避免被下游 `git fetch origin ` 当作选项解析。 + """ + if not ref or not REF_CHARSET_RE.match(ref): + return False + if ref.startswith(("-", "/")) or ref.endswith(("/", ".")): + return False + if ".." in ref or "//" in ref: + return False + for component in ref.split("/"): + if component.startswith(".") or component.endswith(".lock"): + return False + return True + + +def parse(body: str): + lines = [line.strip() for line in (body or "").splitlines() if line.strip()] + if not lines: + raise ValueError(f"评论为空。{USAGE}") + tokens = lines[0].split() + if tokens[0] != "/eval": + raise ValueError(f"首行必须以 /eval 命令开头。{USAGE}") + if len(tokens) < 2: + raise ValueError(f"产品集显式必填。{USAGE}") + + products = [p for p in tokens[1].split(",") if p] + if not products: + raise ValueError(f"产品集显式必填。{USAGE}") + for product in products: + if not PRODUCT_RE.match(product): + raise ValueError(f"产品名非法: {product}。{USAGE}") + + cases_ref = "" + reviewed_sha = "" + for extra in tokens[2:]: + if extra.startswith("cases="): + if cases_ref: + raise ValueError(f"cases 引用只能指定一次。{USAGE}") + cases_ref = extra[len("cases="):] + if not _is_valid_cases_ref(cases_ref): + raise ValueError(f"cases 引用非法: {extra}。{USAGE}") + elif extra.startswith("sha="): + if reviewed_sha: + raise ValueError(f"审核 SHA 只能指定一次。{USAGE}") + reviewed_sha = extra[len("sha="):] + if not SHA_RE.match(reviewed_sha): + raise ValueError(f"审核 SHA 非法: {extra}。{USAGE}") + else: + raise ValueError(f"未知参数: {extra}。{USAGE}") + + if not reviewed_sha: + # 是否允许省略由 guard 按“评论者是否 PR 作者”裁决,解析层不拦 + return ",".join(products), cases_ref, "" + + return ",".join(products), cases_ref, reviewed_sha.lower() + + +def _write_outputs(pairs): + lines = [f"{key}={value}" for key, value in pairs] + output_path = os.environ.get("GITHUB_OUTPUT", "") + if output_path: + with open(output_path, "a", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + print("\n".join(lines)) + + +def main() -> int: + try: + products, cases_ref, reviewed_sha = parse(os.environ.get("COMMENT_BODY", "")) + except ValueError as exc: + _write_outputs([("error", str(exc))]) + print(str(exc), file=sys.stderr) + return 1 + _write_outputs( + [ + ("products", products), + ("cases_ref", cases_ref), + ("reviewed_sha", reviewed_sha), + ] + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/eval_comment_parse_test.py b/scripts/ci/eval_comment_parse_test.py new file mode 100644 index 00000000..419b1f7c --- /dev/null +++ b/scripts/ci/eval_comment_parse_test.py @@ -0,0 +1,92 @@ +import pytest + +from eval_comment_parse import parse + +SHA = "0123456789abcdef0123456789abcdef01234567" + + +def test_single_product(): + assert parse(f"/eval drive sha={SHA}") == ("drive", "", SHA) + + +def test_multiple_products(): + assert parse(f"/eval drive,doc,edu-app sha={SHA}") == ("drive,doc,edu-app", "", SHA) + + +def test_cases_ref_escape_hatch(): + assert parse(f"/eval drive sha={SHA} cases=feat/drive-latest") == ( + "drive", + "feat/drive-latest", + SHA, + ) + + +def test_extra_lines_after_command_are_ignored(): + body = f"/eval drive sha={SHA}\n\n顺便说明:这个 PR 只动了 drive 的 --latest。" + assert parse(body) == ("drive", "", SHA) + + +def test_missing_products_rejected(): + with pytest.raises(ValueError, match="产品集显式必填"): + parse("/eval") + + +def test_omitted_reviewed_sha_defers_to_guard(): + # 是否允许省略由 guard 按“评论者是否 PR 作者”裁决,解析层放行并输出空 reviewed_sha + assert parse("/eval drive") == ("drive", "", "") + + +def test_similar_command_prefix_rejected(): + with pytest.raises(ValueError, match="/eval 命令开头"): + parse(f"/evaluate drive sha={SHA}") + + +def test_illegal_product_name_rejected(): + with pytest.raises(ValueError, match="产品名非法"): + parse(f"/eval drive;rm sha={SHA}") + + +def test_unknown_extra_token_rejected(): + with pytest.raises(ValueError, match="未知参数"): + parse(f"/eval drive sha={SHA} --force") + + +def test_illegal_cases_ref_rejected(): + with pytest.raises(ValueError, match="cases 引用非法"): + parse(f"/eval drive sha={SHA} cases=$(whoami)") + + +def test_structurally_invalid_cases_refs_rejected(): + # git check-ref-format 结构规则:字符合法但结构非法的引用必须拒绝 + invalid_refs = [ + "..", # 以点开头且含连续点号 + "/main", # 首部斜杠 + "feature//x", # 连续斜杠 + "feature/", # 尾部斜杠 + "release.", # 尾部点号 + "a..b", # 任意位置连续点号 + ".hidden", # 分量以点开头 + "a/.hidden", # 非首分量以点开头 + "a.lock", # 分量以 .lock 结尾 + "a/b.lock", # 非首分量以 .lock 结尾 + "-flag", # 以 - 开头,防 git fetch 选项注入 + ] + for ref in invalid_refs: + with pytest.raises(ValueError, match="cases 引用非法"): + parse(f"/eval drive sha={SHA} cases={ref}") + + +def test_structurally_valid_cases_refs_accepted(): + valid_refs = ["main", "feat/drive-latest", "release/v1.0", "a.b/c-d_e", SHA] + for ref in valid_refs: + assert parse(f"/eval drive sha={SHA} cases={ref}") == ("drive", ref, SHA) + + +def test_short_reviewed_sha_rejected(): + with pytest.raises(ValueError, match="审核 SHA 非法"): + parse("/eval drive sha=0123456") + + +def test_empty_comment_rejected(): + with pytest.raises(ValueError, match="评论为空"): + parse(" \n ") diff --git a/scripts/ci/eval_dispatch_guard.py b/scripts/ci/eval_dispatch_guard.py new file mode 100644 index 00000000..d4be187a --- /dev/null +++ b/scripts/ci/eval_dispatch_guard.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""校验 `/eval` 派发的仓库权限与已审核 PR head。""" + +import json +import os +import re +import sys + + +TRUSTED_PERMISSIONS = frozenset({"write", "maintain", "admin"}) +FULL_SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$") + + +def _read_api_response(): + try: + value = json.load(sys.stdin) + except (json.JSONDecodeError, OSError) as exc: + raise ValueError(f"GitHub API response is not valid JSON: {exc}") from exc + if not isinstance(value, dict): + raise ValueError("GitHub API response must be a JSON object") + return value + + +def load_allowlist(path: str) -> frozenset: + """读取自助触发允许名单(默认分支文件;# 注释与空行忽略;大小写不敏感)。""" + if not path: + return frozenset() + try: + with open(path, encoding="utf-8") as f: + lines = f.read().splitlines() + except FileNotFoundError: + return frozenset() + entries = set() + for line in lines: + entry = line.split("#", 1)[0].strip() + if entry: + entries.add(entry.lower()) + return frozenset(entries) + + +def authorize_dispatch(response, commenter: str, pr_author: str, allowlist: frozenset): + """两级授权:仓库写权限可派发任意 PR;名单内用户仅可派发自己创建的 PR。""" + permission = response.get("permission") + if permission in TRUSTED_PERMISSIONS: + return f"maintainer:{permission}" + commenter_key = (commenter or "").strip().lower() + author_key = (pr_author or "").strip().lower() + if commenter_key and commenter_key in allowlist: + if commenter_key == author_key: + return "allowlisted-author" + raise PermissionError( + f"{commenter or 'commenter'} is allowlisted for self-service only " + "and may not dispatch other authors' PRs" + ) + raise PermissionError( + f"{commenter or 'commenter'} does not have write, maintain, or admin permission " + "and is not an allowlisted PR author" + ) + + +def require_reviewed_current_head(response, expected_pr_number: str, reviewed_sha: str, commenter: str): + try: + expected_number = int(expected_pr_number) + except (TypeError, ValueError) as exc: + raise ValueError("expected PR number is invalid") from exc + if response.get("number") != expected_number: + raise ValueError("GitHub API response does not match the requested PR") + if response.get("state") != "open": + raise ValueError("PR is not open") + + head = response.get("head") + current_sha = head.get("sha") if isinstance(head, dict) else "" + if not FULL_SHA_RE.fullmatch(current_sha or ""): + raise ValueError("GitHub API response does not contain a valid PR head SHA") + + if reviewed_sha: + if not FULL_SHA_RE.fullmatch(reviewed_sha): + raise ValueError("reviewed SHA must be a full 40-character commit SHA") + if current_sha.lower() != reviewed_sha.lower(): + raise ValueError( + f"PR head changed after review: reviewed {reviewed_sha.lower()}, " + f"current {current_sha.lower()}" + ) + return reviewed_sha.lower() + + # sha 省略:仅限评论者本人创建的 PR(自助模式)——作者对自己分支的 + # tip 背书不存在第三方偷换窗口;钉住派发时刻的当前 head,内网侧 + # FETCH_HEAD 校验继续兜底派发→执行窗口。评测他人 PR 必须显式带 sha=。 + author = ((response.get("user") or {}).get("login") or "").strip().lower() + commenter_key = (commenter or "").strip().lower() + if not author or commenter_key != author: + raise ValueError( + "dispatching another author's PR requires an explicit reviewed sha=" + ) + return current_sha.lower() + + +def main() -> int: + mode = sys.argv[1] if len(sys.argv) == 2 else "" + try: + response = _read_api_response() + if mode == "permission": + role = authorize_dispatch( + response, + os.environ.get("COMMENTER", ""), + os.environ.get("PR_AUTHOR", ""), + load_allowlist(os.environ.get("EVAL_ALLOWLIST_PATH", "")), + ) + print(f"authorized={role}") + return 0 + if mode == "head": + head_sha = require_reviewed_current_head( + response, + os.environ.get("EXPECTED_PR_NUMBER", ""), + os.environ.get("REVIEWED_SHA", ""), + os.environ.get("COMMENTER", ""), + ) + print(f"head_sha={head_sha}") + return 0 + raise ValueError(f"unknown guard mode: {mode}") + except (PermissionError, ValueError) as exc: + print(str(exc), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/mono/references/products/calendar.md b/skills/mono/references/products/calendar.md index 0dfae700..70b3d2bc 100644 --- a/skills/mono/references/products/calendar.md +++ b/skills/mono/references/products/calendar.md @@ -5,7 +5,8 @@ - **二级子命令(必选其一)**:`event`(日程)、`attendee`(参会人)、`room`(会议室)、`busy`(闲忙)、`attachment`(日程附件)、`book`(我能看哪些日历本)、`acl`(我的日历共享给了谁)。`dws calendar` 后**必须**紧跟上述之一;**禁止**只执行 `dws calendar`(无子命令)。 - **个人日程 / 给自己留时间块 / 专注时段**:统一走 **`dws calendar event create`**。当前**没有**单独的 `personal schedule create` / `calendar create` 命令。 - **查日程列表**:`dws calendar event list --start "" --end "" --format json`,或优先使用脚本 `python scripts/calendar_today_agenda.py [today|tomorrow|week]`(见文末「自动化脚本」)。 -- **查用户日历本列表**:`dws calendar book list`(返回字段为 `calendarId`,主日历 `calendarId == "primary"` 等;注意无 `id` 字段,按 `.id` 提取会取空)。**重要**可以查询他人共享给自己的日历本,根据日历本id可以进一步查询对方的日程信息。 +- **查循环日程实例**:`dws calendar event instances --id --start "" --end "" --format json`,用于按时间范围展开重复日程的每一个实例。**注意:此接口只能查询重复性日程;普通非循环日程将查不到任何实例信息。** +- **查用户日历本列表**:`dws calendar book list`(返回主日历 `id == "primary"` 等)。**重要**可以查询他人共享给自己的日历本,根据日历本id可以进一步查询对方的日程信息。 - **CLI 不存在**独立的 `dws calendar list`;若误跑无子命令的 `dws calendar`,会打印整段 Usage,**切勿**将该段 help 当作工具结果再次塞进对话(会急剧增加 token 与首字延迟)。 - **必须**遵循指令说明进行调用。**绝对禁止**使用虚构指令,使用虚构参数。 @@ -13,7 +14,6 @@ 1. **禁止**执行 `dws calendar` 且不带二级子命令(会刷出大量帮助文本)。合法二级子命令:`event` / `attendee` / `room` / `busy` / `attachment` / `book` / `acl`。 2. **禁止**使用不存在的子命令试探(如臆造 `dws calendar list`);需要日程列表时一律使用 **`dws calendar event list`**(带 `--start` / `--end`,见下文「查询日程列表」示例);需要日历本列表时使用 **`dws calendar book list`**。 3. **禁止**将完整 `--help`/Usage 输出作为「观察」重复提交给模型;若误触,应直接改用本节黄金路径中的合法命令并重试。 -4. **禁止**继续使用已弃用的 `--max-results` 参数 —— 它们仍可被解析但**会被丢弃**,不再透传到 MCP,模型生成命令时也不要再带。 5. **禁止**为已有日程重新创建日程来预订会议室。若日程已存在(同一会话中刚创建、或用户明确指向某日程),必须使用 `room add --event <已有EVENT_ID> --rooms ` 追加会议室,**绝不能**再调一次 `event create --rooms`(会创建重复日程)。 6. **禁止**用 `--location` 替代会议室预订。`--location` 是纯文本地点备注字段,填入会议室名称**不会**完成任何预订或占用。预订会议室必须通过 `room add --rooms ` 或 `event create --rooms `,roomId 来自 `room search` 返回;`--location` 与 `--rooms` 是两个独立字段,用途完全不同。 7. **禁止**只传 `--recurrence-*` 部分 flag **并不是彼此独立的参数**:只传其中一项(比如只改 `--recurrence-count`、只设 `--recurrence-type`)会让服务端收到不完整的 recurrence 结构,CLI 现已前置校验并直接拒绝这类调用。**修改已有周期日程的任何一个循环字段时,都必须重新提供完整的 pattern+range 字段集合**——必要时先 `event get` 读取现有 `recurrence`,再在命令中整体重传。 @@ -26,7 +26,7 @@ 日程实例(event instance):日程的具体时间实例,可以通过event list指令查询时间段内的所有实例。1个普通日程和对应1个Instance,而1个重复性日程(SeriesMaster)对应N个Instance(同属一个日程序列)。 - 同一个日程序列具有相同的iCalUid,并且重复性日程,其eventId和iCalUid的值相同。因此可以通过重复性日程实例的iCalUid得到重复性日程(SeriesMaster)的eventId 重复规则(recurrence rule):定义重复性日程的重复规则。 -参会人(attendee):日程的参与者。常用通讯录工具查询userId,dws contact user search --keyword "姓名"。 +参会人(attendee):日程的参与者。按姓名统一使用 `dws aisearch person --keyword "姓名" --dimension name --format json` 查询 userId。 响应状态(response):参会人对日程的回应,包括:未响应、接受、待定、拒绝。 忙闲时间(busy):查询用户在指定时间段的忙闲状态,查询会议室在指定时间段的预定状态,用于会议时间协调。 会议室(room):room是 会议室 ,room可视为日程的资源类参会人,需要加入日程完成预订。注意和location区分,location只是地点,和room不同。 @@ -42,6 +42,8 @@ dws calendar event [create|update|get|delete|respond] [flags] # 按时间范围批量查询 dws calendar event list [flags] +# 查询循环日程的实例列表(按时间范围展开重复日程) +dws calendar event instances [flags] # 对于非明确时间或一段时间范围的约会场景,可基于所有参会人的忙闲状态,推荐多个可用的时间块方案 dws calendar event suggest [flags] ``` @@ -64,7 +66,6 @@ dws calendar room add [flags] dws calendar room delete [flags] ``` > room是会议室,用于线下开会场景。 -> **组织限制**:部分企业使用自建会议室系统,未接入钉钉会议室能力。此时 `room search` / `room list-groups` 会返回业务错误 `400056`("所选组织不支持预定钉钉会议室")。这是组织级配置限制,不是命令用法问题;应直接告知用户该组织需在钉钉客户端手动预订会议室,不要重试或换参。 ### busy 相关三级子命令 ``` @@ -109,17 +110,21 @@ Usage: dws calendar event list [flags] Example: dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T18:00:00+08:00" + dws calendar event list --start "2026-03-10T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00" --limit 50 dws calendar event list --calendar-id primary + dws calendar event list --cursor "" Flags: --calendar-id string 日历 ID (默认 primary 主日历,仅在查询其他日历本时填写;通过 `book list` 获取) + --cursor string 分页游标 (从上一次返回的 nextCursor 获取,首次查询无需传入) --end string 结束时间 ISO-8601 (例如 2026-03-10T18:00:00+08:00) + --limit int 每页返回条数 (默认 100,最大 100) --start string 开始时间 ISO-8601 (例如 2026-03-10T14:00:00+08:00) ``` -> `--max-results` 已弃用:MCP 不再支持,CLI 仍接受但参数会被丢弃,结果固定最多返回 100 条。 - **默认行为**:不传 `--start` / `--end` 时,默认返回今天的日程(00:00:00 ~ 23:59:59)。 **权限**:查询共享日历下的日程时,至少要有reader权限。 +**分页**:单次最多返回 `--limit` 指定的条数(默认/最大 100);当结果超过 limit 时,返回体包含 `nextCursor` 字段。首次查询无需传 `--cursor`,仅在翻页时将上一次返回的 `nextCursor` 作为 `--cursor` 传入。 + ### 获取日程详情 ``` @@ -133,6 +138,28 @@ Flags: --calendar-id string 日历 ID (默认 primary 主日历) ``` +### 查询循环日程实例 +``` +Usage: + dws calendar event instances [flags] +Example: + dws calendar event instances --id + dws calendar event instances --id --start "2026-03-10T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00" + dws calendar event instances --id --limit 50 + dws calendar event instances --id --cursor "" +Flags: + --id string 日程 ID (必填,重复性日程 SeriesMaster 的 eventId) + --calendar-id string 日历 ID (默认 primary 主日历,仅在查询其他日历本时填写;通过 `book list` 获取) + --start string 开始时间 ISO-8601 (例如 2026-03-10T00:00:00+08:00,不传则默认今天 00:00:00) + --end string 结束时间 ISO-8601 (例如 2026-03-31T23:59:59+08:00,不传则默认今天 23:59:59) + --limit int 每页返回条数 (默认 100,最大 100) + --cursor string 分页游标 (从上一次返回的 nextCursor 获取,首次查询无需传入) +``` + +> **说明**:用于按时间范围展开重复日程(SeriesMaster)的每一个实例。**此接口只能查询重复性日程;若传入普通非循环日程,将查不到任何实例信息。**`--id` 必须是重复性日程的 eventId,可通过 `event list` 获取。 +> **默认行为**:不传 `--start` / `--end` 时,默认返回今天的实例(00:00:00 ~ 23:59:59)。 +> **分页**:单次最多返回 `--limit` 指定的条数(默认/最大 100);当结果超过 limit 时,返回体包含 `nextCursor` 字段。 + ### 创建日程 ``` Usage: @@ -149,10 +176,17 @@ Example: dws calendar event create --title "每日站会" \ --start "2026-03-10T09:00:00+08:00" --end "2026-03-10T09:30:00+08:00" \ --recurrence-type daily --recurrence-interval 1 --recurrence-range-type numbered --recurrence-count 10 + dws calendar event create --title "团队周会" \ + --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" \ + --calendar-id # 在指定日历本下创建日程 + dws calendar event create --title "重要会议" \ + --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" \ + --remind-minutes 5,10 # 开始前5分钟和10分钟各提醒一次 Flags: --title string 日程标题 (必填,最大2048字符) --start string 开始时间 ISO-8601 (必填,例如 2026-03-10T14:00:00+08:00) --end string 结束时间 ISO-8601 (必填,例如 2026-03-10T15:00:00+08:00) + --calendar-id string 日历 ID (可选,默认 primary 主日历;仅在共享/订阅日历本下创建时填写,通过 `book list` 获取) --timezone string 时区 IANA 格式 (例如 Asia/Shanghai,默认 Asia/Shanghai) --desc string 日程描述 (最大5000字符) --attendees string 参会人 userId 列表,逗号分隔 (最多500人) 日程组织人自动放入参会人列表,无需传入userId @@ -172,6 +206,7 @@ Flags: --rich-text-desc string html格式的富文本类型日程描述,用于复杂内容的展示 --location string 地点信息(纯文本备注,如‘3号楼A区’;**不等于**预订会议室) --free-busy string 此日程的忙碌状态,默认值为busy。busy - 在忙闲视图中,此日程时间段为忙碌; free - 此日程不占用忙闲 + --remind-minutes string 日程开始前提醒,逗号分隔分钟数 (可选,例如 5,10,15 表示开始前5/10/15分钟提醒;不传则默认15分钟提醒) ``` > **说明**:个人日程也走 `event create`。如果只是给自己安排时间,不传 `--attendees` / `--open-dingtalk-ids` 即可。 @@ -185,8 +220,10 @@ Example: dws calendar event update --id --desc "新描述" --timezone Asia/Tokyo dws calendar event update --id --recurrence-type daily --recurrence-interval 1 \ --recurrence-range-type numbered --recurrence-count 5 + dws calendar event update --id --calendar-id --title "新标题" # 修改其他日历本下的日程 Flags: --id string 日程 ID (必填) + --calendar-id string 日历 ID (可选,默认 primary 主日历;指定其他日历本时填写,可通过 `book list` 获取) --title string 新标题 --start string 新开始时间 ISO-8601 --end string 新结束时间 ISO-8601 @@ -218,8 +255,10 @@ Usage: dws calendar event delete [flags] Example: dws calendar event delete --id --yes + dws calendar event delete --id --calendar-id --yes # 删除其他日历本下的日程 Flags: - --id string 日程 ID (必填) + --id string 日程 ID (必填) + --calendar-id string 日历 ID (可选,默认 primary 主日历;指定其他日历本时填写,可通过 `book list` 获取) ``` ### 查看参会人 @@ -228,8 +267,10 @@ Usage: dws calendar attendee list [flags] Example: dws calendar attendee list --event + dws calendar attendee list --event --calendar-id # 查看其他日历本下日程的参会人 Flags: - --event string 日程 ID (必填) + --event string 日程 ID (必填) + --calendar-id string 日历 ID (可选,默认 primary 主日历;指定其他日历本时填写,可通过 `book list` 获取, 注意:订阅日历下的日程无参会人,因此不可查看) ``` ### 添加参会人 @@ -239,10 +280,12 @@ Usage: Example: dws calendar attendee add --event --attendees , dws calendar attendee add --event --attendees --optional + dws calendar attendee add --event --attendees --calendar-id # 给其他日历本下的日程添加参会人 Flags: - --event string 日程 ID (必填) - --attendees string 参会人 userId 列表,逗号分隔 (必填,最多500人) - --optional 参会人可选 (默认必选参会人) + --event string 日程 ID (必填) + --attendees string 参会人 userId 列表,逗号分隔 (必填,最多500人) + --optional 参会人可选 (默认必选参会人) + --calendar-id string 日历 ID (可选,默认 primary 主日历;指定其他日历本时填写,可通过 `book list` 获取,注意:订阅日历下的日程无参会人,因此不可添加) ``` ### 移除参会人 @@ -254,36 +297,54 @@ Usage: dws calendar attendee delete [flags] Example: dws calendar attendee delete --event --attendees --yes + dws calendar attendee delete --event --attendees --calendar-id --yes # 移除其他日历本下日程的参会人 Flags: - --event string 日程 ID (必填) - --attendees string 参会人 userId 列表,逗号分隔 (必填) + --event string 日程 ID (必填) + --attendees string 参会人 userId 列表,逗号分隔 (必填) + --calendar-id string 日历 ID (可选,默认 primary 主日历;指定其他日历本时填写,可通过 `book list` 获取,注意:订阅日历下的日程无参会人,因此不可移除) ``` ### 搜索会议室 -> 此指令可用于搜索当前用户可用的会议室。**注意**,大部分会议室**仅在工作时间**可用,如果检索时间不在工作时间可能查不到任何结果。 +> 此指令支持两种模式:**按名称搜索**(不传 --start/--end)和**按时间段搜索可用会议室**(传 --start/--end 或不传任何参数)。 > 此指令搜索到的会议室结果中,有两个值需要注意: - - customApprovalProcess: true - 表示该会议室设置了自定义审批流程,只能通过客户端完成预订。 - - supportRecurring: true - 表示该会议室支持循环预定;false - 表示不支持循环预定,直接加入到循环日程会失败。 +> - customApprovalProcess: true - 表示该会议室设置了自定义审批流程,只能通过客户端完成预订。 +> - supportRecurring: true - 表示该会议室支持循环预定;false - 表示不支持循环预定,直接加入到循环日程会失败。 + +**模式路由规则**: +- **按名称搜索**:仅传 `--room-name`,不传 `--start`/`--end` → 返回所有匹配名称的会议室,**不检查可用性**。适用于「找到某个会议室」的场景。 +- **按时间段搜索可用会议室**:传 `--start`/`--end`,或不传任何参数 → 返回指定时间段内**可用**的会议室。不传时间时默认当前时间起 1 小时。 ``` Usage: dws calendar room search [flags] Example: + # 按名称搜索(不检查可用性,返回所有匹配的会议室) + dws calendar room search --room-name 永澄亭 # 注意:用户即使说「永澄亭会议室」,也应仅传「永澄亭」 + + # 按时间段搜索可用会议室 dws calendar room search --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" dws calendar room search --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" --group-id - dws calendar room search --room-name 永澄亭 # 注意:用户即使说「永澄亭会议室」,也应仅传「永澄亭」 dws calendar room search # 不传 --start/--end 时默认当前时间起 1 小时 + + # 名称 + 时间段:搜索指定名称的可用会议室 + dws calendar room search --room-name 永澄亭 --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" + + # 分页(仅按时间段搜索时有效) + dws calendar room search --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" --limit 20 --page 0 Flags: --start string 开始时间 ISO-8601 (可选,不传则默认当前时间+1分钟缓冲) --end string 结束时间 ISO-8601 (可选,不传则默认当前时间+1 小时) - --group-id string 会议室分组ID(可选,留空查根目录;超100条时需按分组查询) - --room-name string 按会议室名称过滤(可选,服务端模糊匹配;传入前必须由调用方精简,只保留核心专名) + --group-id string 会议室分组ID(可选,留空查根目录;超100条时需按分组查询;仅按时间段搜索时有效) + --room-name string 会议室名称(按名称搜索时必填;按时间段搜索时可选,用于过滤) + --limit string 页大小 (可选,不填默认 100,超过 100 按 100 处理;仅按时间段搜索时有效) + --page string 分页起始位置 (可选,不填默认 0;仅按时间段搜索时有效) ``` > **时间约束(API 限制)**:`start` 必须是未来的时间(服务端校验:start can not less current time)。 > - 若传入的 `--start` 早于当前时间,CLI 会自动修正为 `now + 1min`,调用方无需额外处理。 > - 若传入的 `--end` 早于当前时间,CLI 直接报错——无法检索已过去的时间段。 > - **最佳实践**:调用方在组装时间参数时应确保 start/end 都是未来时间;若不确定,可省略 `--start`/`--end` 让 CLI 使用默认值(当前时间起 1 小时)。 +> - **注意**:时间约束仅在「按时间段搜索」模式下生效。按名称搜索(仅传 `--room-name`)不受时间约束。 **名称过滤使用规范**:`--room-name` 适用于用户说「预定永澄亭」「约西湖厅」这类按名找会议室的场景。 - **服务端是模糊匹配,但匹配词越精简命中率越高**,关键疗法:**调用方必须在调用 CLI 前自行精简名称,CLI 不会再做任何删减**。 @@ -293,13 +354,16 @@ Flags: - 用户:「西湖厅有空吗」 → `--room-name 西湖厅`(本身就是专名,不删即可) - 用户:「预定贡嘎山大会议室」 → `--room-name 贡嘎山` -**优先路径**:当用户给了会议室中文名时,**优先用 `room search --room-name <核心专名> --start <开始时间> --end <结束时间>`**代替「先走 `list-groups`再遍历」的旧流程。`--room-name` 可与 `--group-id` 同时使用,表示「在指定分组内按名称过滤」。若返回空列表,再降级使用 `list-groups` 定位分组再查。 +**优先路径**: +- 当用户仅给出会议室名称、未指定时间段时,用 `room search --room-name <核心专名>` 快速找到会议室(不检查可用性)。 +- 当用户给出会议室名称且指定了时间段时,用 `room search --room-name <核心专名> --start <开始时间> --end <结束时间>` 查询该名称的可用会议室。 +- 若返回空列表,再降级使用 `list-groups` 定位分组再查。`--room-name` 可与 `--group-id` 同时使用(仅按时间段搜索时),表示「在指定分组内按名称过滤」。 **`roomId` 与用户说的话不是一回事**:用户说的「C6-4-06-N 贡嘎山」等是**展示名/编号文案**,**绝不能**直接填进 `room add --rooms`。`--rooms` 只接受上一步 `room search`(或同类接口)返回 JSON 里的 **`rooms[].roomId`**。形态上多为**小写十六进制串**(长度以接口为准,例如 `e6b7b65b8b30fb707afcf6c3b699f028003e6834fdd7fee7`)。含**中文、空格、连字符拼接的楼层编号**、或凭空调 UUID/纯数字「试格式」——一律视为非法,必须先搜房再取返回字段。 > 如果知道roomId,想查该会议室的预订记录,直接用dws calendar busy search 指令 ---- +--- ### 预定会议室 ``` @@ -307,9 +371,11 @@ Usage: dws calendar room add [flags] Example: dws calendar room add --event --rooms + dws calendar room add --event --rooms --calendar-id # 给其他日历本下的日程预定会议室 Flags: - --event string 日程 ID (必填) - --rooms string 会议室 ID 列表 (必填) + --event string 日程 ID (必填) + --rooms string 会议室 ID 列表 (必填) + --calendar-id string 日历 ID (可选,默认 primary 主日历;指定其他日历本时填写,可通过 `book list` 获取,注意:订阅日历下的日程不可添加会议室) ``` > room是会议室,用于线下开会场景。将room加入到日程完成预订 > 重复性日程,预订会议室时,必须设置 循环结束时间(recurrence-end-date),noEnd 或者 指定循环次数 都无法完成预定。 @@ -324,9 +390,11 @@ Usage: dws calendar room delete [flags] Example: dws calendar room delete --event --rooms --yes + dws calendar room delete --event --rooms --calendar-id --yes # 移除其他日历本下日程的会议室 Flags: - --event string 日程 ID (必填) - --rooms string 会议室 ID 列表 (必填) + --event string 日程 ID (必填) + --rooms string 会议室 ID 列表 (必填) + --calendar-id string 日历 ID (可选,默认 primary 主日历;指定其他日历本时填写,可通过 `book list` 获取。注意:订阅日历下的日程不可添加会议室) ``` ### 会议室分组列表 @@ -347,22 +415,24 @@ Usage: dws calendar attachment add [flags] Example: dws calendar attachment add --event --files :report.pdf,:slides.pptx + dws calendar attachment add --event --files :report.pdf --calendar-id # 给其他日历本下的日程添加附件 Flags: - --event string 日程 ID (必填) - --files string 附件列表,格式 :,多项逗号分隔 (必填) + --event string 日程 ID (必填) + --files string 附件列表,格式 :,多项逗号分隔 (必填) + --calendar-id string 日历 ID (可选,默认 primary 主日历;指定其他日历本时填写,可通过 `book list` 获取,注意:订阅日历下的日程不可添加附件) ``` > 上传文件得到 `fileId` 需配合钉盘相关流程;本命令只负责把已上传的文件挂载到日程上。 -### 查询用户日历列表 +### 查询我能看的所有日历本 ``` Usage: dws calendar book list [flags] Example: dws calendar book list ``` -> 通过此接口可查询当前用户的日历列表,包含 主日历本、他人共享的日历、订阅的公共/团队日历。查的是"我能看哪些日历本";注意区分:`acl list` 是查"我的日历共享**给了谁**",方向相反。 -> 共享日历本中有来自 xxx 的,且权限大于reader,那么通过 `event list --calendar-id `可查到xxx完整的日程安排 +> 查询"我能看哪些日历本",包含:我的主日历、他人共享**给我**的日历、订阅的公共/团队日历。注意区分:`acl list` 是查"我的日历共享**给了谁**",方向相反。 +> 共享日历本中有来自 xxx 的,且权限大于reader,那么通过 `event list --calendar-id `可查到xxx完整的日程安排。 > 主日历 `id` 固定为 `primary`,绝大多数日程操作都默认走主日历,只有当用户明确要求查/写其他日历本时才需要带 `--calendar-id`。 ### 查询指定日历本 @@ -495,9 +565,11 @@ Example: dws calendar event respond --id --status accepted dws calendar event respond --id --status declined dws calendar event respond --id --status tentative + dws calendar event respond --id --status accepted --calendar-id # 响应其他日历本下的日程 Flags: - --id string 日程 ID (必填) - --status string 响应状态: needsAction(未操作)|accepted(接受)|declined(拒绝)|tentative(暂定) (必填) + --id string 日程 ID (必填) + --status string 响应状态: needsAction(未操作)|accepted(接受)|declined(拒绝)|tentative(暂定) (必填) + --calendar-id string 日历 ID (可选,默认 primary 主日历;指定其他日历本时填写,可通过 `book list` 获取。注意:订阅日历下的日程无参会人,因此不可响应) ``` > **说明**:作为日程参会人,设置自己的响应状态(接受、拒绝、暂定)。`--status` 可选值:`needsAction`(未操作,默认值)、`accepted`(接受)、`declined`(拒绝)、`tentative`(暂定)。 @@ -507,11 +579,12 @@ Flags: 用户说"日程/会议/约会/日历": - 查看 → `event list` - 详情 → `event get` -- 创建/约/给自己留时间块/个人日程 → `event create`(带参会人时加 `--attendees`,循环日程加 `--recurrence-*`) +- 创建/约/给自己留时间块/个人日程 → `event create`(带参会人时加 `--attendees`,循环日程加 `--recurrence-*`,自定义提醒加 `--remind-minutes`) - 修改/改时间/改描述 → `event update`(支持修改标题、时间、描述、时区、循环规则) - 取消/删除 → `event delete` - 推荐时间/什么时候有空/协调时间 → `event suggest` - 接受/拒绝/暂定日程 → `event respond` +- 查询循环日程/重复日程的每次实例/展开循环日程 → `event instances` 用户说"参会人/与会者": - 查看 → `attendee list` @@ -519,13 +592,14 @@ Flags: - 移除 → `attendee delete --attendees ` 用户说"会议室/订会议室": -- 哪个空闲 → `room search` -- 按名找会议室(如「永澄亭」「永澄亭会议室」「约西湖厅」)→ 先在模型层精简名称(剔除「会议室」等通用后缀),再用 `room search --room-name <核心专名>` +- 哪个空闲 → `room search`(默认查当前时间起 1 小时内可用会议室) +- 按名找会议室(如「永澄亭」「永澄亭会议室」「约西湖厅」,未提时间段)→ 先在模型层精简名称(剔除「会议室」等通用后缀),再用 `room search --room-name <核心专名>`(按名称搜索,不检查可用性) +- 按名找可用会议室(如「永澄亭下午 2 点有空吗」)→ `room search --room-name <核心专名> --start <开始时间> --end <结束时间>`(按名称+时间段搜索可用会议室) - 预订 - 给已有日程订会议室 → `room add --event <已有EVENT_ID> --rooms ` - 创建新日程并订会议室 → `event create --rooms`(仅当日程尚不存在时) - 取消预定 → `room delete` -- 分组 → `room list-groups`,取 groupId 后 `room search --group-id`(可再叠加 `--room-name` 在分组内过滤) +- 分组 → `room list-groups`,取 groupId 后 `room search --group-id`(需配合 `--start`/`--end` 按时间段搜索;可再叠加 `--room-name` 在分组内过滤) 用户说"有空吗/忙不忙/闲忙": - 查询用户闲忙 → `busy search --users ` @@ -580,11 +654,11 @@ dws calendar event create --title "Q1 复盘会" \ **方式二:先创建日程,再单独添加参会人 / 会议室** ```bash -# Step 1: 创建日程 — 提取 eventId +# Step 1: 创建日程 — 从 result.id 提取日程 ID dws calendar event create --title "Q1 复盘会" \ --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" --format json -# Step 2: 添加参会人(必须用 Step 1 返回的 eventId) +# Step 2: 添加参会人(必须用 Step 1 返回的 result.id) dws calendar attendee add --event --attendees userId1,userId2 --format json # Step 3: 搜索空闲会议室 @@ -612,13 +686,14 @@ dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15 | 操作 | 从返回中提取 | 用于 | |------|-------------|------| -| `event create` | `eventId` | attendee/room/attachment 操作的 --event | -| `event list` | `events[].eventId` | event get/update/delete/respond 的 --id | +| `event create` | `result.id` | attendee/room/attachment 操作的 --event | +| `event list` | `result.events[].id`, `nextCursor` | event get/update/delete/respond 的 --id;下一页 --cursor | | `event suggest` | 推荐的时间段 | event create 的 --start/--end | | `event respond` | 响应结果 | — | +| `event instances` | `result.events[].id`, `nextCursor` | event get/update/delete/respond 的 --id;下一页 --cursor | | `room search` | `rooms[].roomId` | room add 的 --rooms 或 event create 的 --rooms | | `room list-groups` | `groups[].groupId` | room search 的 --group-id | -| `book list` | `calendarId`(如 `primary`) | event list/get 的 --calendar-id, book get/update 的 --id | +| `book list` | `id`(如 `primary`) | event list/get 的 --calendar-id, book get/update 的 --id | | `book get` | 日历详细信息 | — | | `book search` | 匹配的日历列表 | book get/update 的 --id | | `acl list` | `aclId` | acl delete 的 --acl-id | @@ -638,20 +713,23 @@ dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15 - `room list-groups` 支持 `--limit` / `--page` 分页(schema 类型为字符串) - **`event create --rooms` / `room add --rooms` 的唯一合法来源**:最近一次(同一会话、同一时段窗口)`room search` 返回体中的 `roomId`;禁止把用户自然语言会议室名当 `roomId` 传入(否则会 `roomId invalid` 等错误) - **搜房无结果**:在符合早停/用户限定范围内,`room search`(含按分组逐组查)全部返回空或无空闲 → 应**直接向用户报错/说明失败**并结束订房;**禁止**假设 roomId、禁止无合法 `roomId` 时调用 `room add` / `event create --rooms` 试探、禁止用 `event get` 等绕路推断 roomId -- **评测 / 自动化断言**:凡涉及 `room add` / `event create --rooms` 的流程,`--rooms` 只能填上游 `room search`(或等价接口)返回 JSON 中的 **`rooms[].roomId`**;不得以会议室展示名、楼层文案或用户口语当作 `roomId` +- **自动化校验**:凡涉及 `room add` / `event create --rooms` 的流程,`--rooms` 只能填上游 `room search`(或等价接口)返回 JSON 中的 **`rooms[].roomId`**;不得以会议室展示名、楼层文案或用户口语当作 `roomId` - **附件**:`attachment add` 仅负责挂载,**不上传**文件;fileId 必须先通过钉盘流程取得;`--files` 多附件用 `:` 元素逗号分隔 -- **日历本**:`book list` 返回的 `calendarId` 字段才是合法日历 id(无 `id` 字段);如无明确说明,`event list` / `event get` 都不要带 `--calendar-id`,让接口默认走 primary 主日历 -- **已弃用入参**:`--max-results`(event list)在新 MCP schema 中被移除;CLI 仍接受但**不会**透传到 MCP;模型生成命令时**禁止**继续使用 +- **日历本**:`book list` 返回的 `id` 才是合法 `calendarId`;如无明确说明,`event list` / `event get` 都不要带 `--calendar-id`,让接口默认走 primary 主日历 +- **分页查询**:`event list` / `event instances` 均支持 `--limit`(控制每页条数,默认/最大 100)和 `--cursor`(翻页游标);**首次查询无需传 `--cursor`**,仅当返回体中包含 `nextCursor` 时,将其作为 `--cursor` 传入可获取下一页 +- **循环日程实例**:`event instances` 用于按时间范围展开重复日程(SeriesMaster)的每一个实例;**普通非循环日程调用该命令将查不到任何实例信息** +- **日程提醒**:`event create` 支持 `--remind-minutes` 设置开始前提醒,逗号分隔多个分钟数(如 `--remind-minutes 5,10,15`),不传则默认15分钟提醒 +- **会议室分页**:`room search` 支持 `--limit`(每页条数,默认100,最大100)和 `--page`(分页起始位置,默认0),与 `room list-groups` 分页风格一致 ## 自动化脚本 | 脚本 | 场景 | 用法 | |------|------|------| -| [calendar_today_agenda.py](../../scripts/calendar_today_agenda.py) | 查看今天/明天/本周日程安排 | `python calendar_today_agenda.py today` | -| [calendar_schedule_meeting.py](../../scripts/calendar_schedule_meeting.py) | 一键创建日程+添加参会人+预定会议室;搜房失败时输出明确原因并返回非零退出码 | `python calendar_schedule_meeting.py --title "复盘会" --start "2026-03-15T14:00" --end "2026-03-15T15:00" --users userId1 --book-room` | -| [calendar_free_slot_finder.py](../../scripts/calendar_free_slot_finder.py) | 查询多人共同空闲时段 | `python calendar_free_slot_finder.py --users userId1,userId2 --date 2026-03-15` | +| [calendar_today_agenda.py](../scripts/calendar_today_agenda.py) | 查看今天/明天/本周日程安排 | `python calendar_today_agenda.py today` | +| [calendar_schedule_meeting.py](../scripts/calendar_schedule_meeting.py) | 一键创建日程+添加参会人+预定会议室;搜房失败时输出明确原因并返回非零退出码 | `python calendar_schedule_meeting.py --title "复盘会" --start "2026-03-15T14:00" --end "2026-03-15T15:00" --users userId1 --book-room` | +| [calendar_free_slot_finder.py](../scripts/calendar_free_slot_finder.py) | 查询多人共同空闲时段 | `python calendar_free_slot_finder.py --users userId1,userId2 --date 2026-03-15` | ## 相关产品 -- conference 视频会议 — 当前 CLI 不支持发起/预约/邀请入会/会中控制;请在钉钉客户端操作 -- [contact](./contact.md) — 搜索同事 userId,用于 attendee add --attendees +- conference(视频会议预约) — 仅视频会议预约(返回入会链接),不含参会人/会议室管理 +- [contact](../../dingtalk-contact/references/contact.md) — 搜索同事 userId,用于 attendee add --attendees diff --git a/skills/multi/dingtalk-calendar/references/calendar.md b/skills/multi/dingtalk-calendar/references/calendar.md index 9bb4c8eb..70b3d2bc 100644 --- a/skills/multi/dingtalk-calendar/references/calendar.md +++ b/skills/multi/dingtalk-calendar/references/calendar.md @@ -5,6 +5,7 @@ - **二级子命令(必选其一)**:`event`(日程)、`attendee`(参会人)、`room`(会议室)、`busy`(闲忙)、`attachment`(日程附件)、`book`(我能看哪些日历本)、`acl`(我的日历共享给了谁)。`dws calendar` 后**必须**紧跟上述之一;**禁止**只执行 `dws calendar`(无子命令)。 - **个人日程 / 给自己留时间块 / 专注时段**:统一走 **`dws calendar event create`**。当前**没有**单独的 `personal schedule create` / `calendar create` 命令。 - **查日程列表**:`dws calendar event list --start "" --end "" --format json`,或优先使用脚本 `python scripts/calendar_today_agenda.py [today|tomorrow|week]`(见文末「自动化脚本」)。 +- **查循环日程实例**:`dws calendar event instances --id --start "" --end "" --format json`,用于按时间范围展开重复日程的每一个实例。**注意:此接口只能查询重复性日程;普通非循环日程将查不到任何实例信息。** - **查用户日历本列表**:`dws calendar book list`(返回主日历 `id == "primary"` 等)。**重要**可以查询他人共享给自己的日历本,根据日历本id可以进一步查询对方的日程信息。 - **CLI 不存在**独立的 `dws calendar list`;若误跑无子命令的 `dws calendar`,会打印整段 Usage,**切勿**将该段 help 当作工具结果再次塞进对话(会急剧增加 token 与首字延迟)。 - **必须**遵循指令说明进行调用。**绝对禁止**使用虚构指令,使用虚构参数。 @@ -41,6 +42,8 @@ dws calendar event [create|update|get|delete|respond] [flags] # 按时间范围批量查询 dws calendar event list [flags] +# 查询循环日程的实例列表(按时间范围展开重复日程) +dws calendar event instances [flags] # 对于非明确时间或一段时间范围的约会场景,可基于所有参会人的忙闲状态,推荐多个可用的时间块方案 dws calendar event suggest [flags] ``` @@ -135,6 +138,28 @@ Flags: --calendar-id string 日历 ID (默认 primary 主日历) ``` +### 查询循环日程实例 +``` +Usage: + dws calendar event instances [flags] +Example: + dws calendar event instances --id + dws calendar event instances --id --start "2026-03-10T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00" + dws calendar event instances --id --limit 50 + dws calendar event instances --id --cursor "" +Flags: + --id string 日程 ID (必填,重复性日程 SeriesMaster 的 eventId) + --calendar-id string 日历 ID (默认 primary 主日历,仅在查询其他日历本时填写;通过 `book list` 获取) + --start string 开始时间 ISO-8601 (例如 2026-03-10T00:00:00+08:00,不传则默认今天 00:00:00) + --end string 结束时间 ISO-8601 (例如 2026-03-31T23:59:59+08:00,不传则默认今天 23:59:59) + --limit int 每页返回条数 (默认 100,最大 100) + --cursor string 分页游标 (从上一次返回的 nextCursor 获取,首次查询无需传入) +``` + +> **说明**:用于按时间范围展开重复日程(SeriesMaster)的每一个实例。**此接口只能查询重复性日程;若传入普通非循环日程,将查不到任何实例信息。**`--id` 必须是重复性日程的 eventId,可通过 `event list` 获取。 +> **默认行为**:不传 `--start` / `--end` 时,默认返回今天的实例(00:00:00 ~ 23:59:59)。 +> **分页**:单次最多返回 `--limit` 指定的条数(默认/最大 100);当结果超过 limit 时,返回体包含 `nextCursor` 字段。 + ### 创建日程 ``` Usage: @@ -559,6 +584,7 @@ Flags: - 取消/删除 → `event delete` - 推荐时间/什么时候有空/协调时间 → `event suggest` - 接受/拒绝/暂定日程 → `event respond` +- 查询循环日程/重复日程的每次实例/展开循环日程 → `event instances` 用户说"参会人/与会者": - 查看 → `attendee list` @@ -664,6 +690,7 @@ dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15 | `event list` | `result.events[].id`, `nextCursor` | event get/update/delete/respond 的 --id;下一页 --cursor | | `event suggest` | 推荐的时间段 | event create 的 --start/--end | | `event respond` | 响应结果 | — | +| `event instances` | `result.events[].id`, `nextCursor` | event get/update/delete/respond 的 --id;下一页 --cursor | | `room search` | `rooms[].roomId` | room add 的 --rooms 或 event create 的 --rooms | | `room list-groups` | `groups[].groupId` | room search 的 --group-id | | `book list` | `id`(如 `primary`) | event list/get 的 --calendar-id, book get/update 的 --id | @@ -689,7 +716,8 @@ dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15 - **自动化校验**:凡涉及 `room add` / `event create --rooms` 的流程,`--rooms` 只能填上游 `room search`(或等价接口)返回 JSON 中的 **`rooms[].roomId`**;不得以会议室展示名、楼层文案或用户口语当作 `roomId` - **附件**:`attachment add` 仅负责挂载,**不上传**文件;fileId 必须先通过钉盘流程取得;`--files` 多附件用 `:` 元素逗号分隔 - **日历本**:`book list` 返回的 `id` 才是合法 `calendarId`;如无明确说明,`event list` / `event get` 都不要带 `--calendar-id`,让接口默认走 primary 主日历 -- **分页查询**:`event list` 支持 `--limit`(控制每页条数,默认/最大 100)和 `--cursor`(翻页游标);**首次查询无需传 `--cursor`**,仅当返回体中包含 `nextCursor` 时,将其作为 `--cursor` 传入可获取下一页 +- **分页查询**:`event list` / `event instances` 均支持 `--limit`(控制每页条数,默认/最大 100)和 `--cursor`(翻页游标);**首次查询无需传 `--cursor`**,仅当返回体中包含 `nextCursor` 时,将其作为 `--cursor` 传入可获取下一页 +- **循环日程实例**:`event instances` 用于按时间范围展开重复日程(SeriesMaster)的每一个实例;**普通非循环日程调用该命令将查不到任何实例信息** - **日程提醒**:`event create` 支持 `--remind-minutes` 设置开始前提醒,逗号分隔多个分钟数(如 `--remind-minutes 5,10,15`),不传则默认15分钟提醒 - **会议室分页**:`room search` 支持 `--limit`(每页条数,默认100,最大100)和 `--page`(分页起始位置,默认0),与 `room list-groups` 分页风格一致 @@ -703,5 +731,5 @@ dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15 ## 相关产品 -- 视频会议(conference)— 当前 CLI 不支持发起/预约/邀请入会/会中控制;请在钉钉客户端操作 +- conference(视频会议预约) — 仅视频会议预约(返回入会链接),不含参会人/会议室管理 - [contact](../../dingtalk-contact/references/contact.md) — 搜索同事 userId,用于 attendee add --attendees diff --git a/test/scripts/eval_dispatch_workflow_test.go b/test/scripts/eval_dispatch_workflow_test.go new file mode 100644 index 00000000..235d1f31 --- /dev/null +++ b/test/scripts/eval_dispatch_workflow_test.go @@ -0,0 +1,291 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package scripts_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestEvalDispatchWorkflowUsesRepositoryPermissionAndReviewedSHA(t *testing.T) { + t.Parallel() + + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "eval-dispatch.yml")) + if err != nil { + t.Fatalf("read eval-dispatch workflow: %v", err) + } + workflow := string(data) + + for _, want := range []string{ + "/collaborators/${COMMENTER}/permission", + "eval_dispatch_guard.py permission", + "PR_AUTHOR: ${{ github.event.issue.user.login }}", + "EVAL_ALLOWLIST_PATH: .github/eval-allowlist.txt", + "REVIEWED_SHA: ${{ steps.parse.outputs.reviewed_sha }}", + "eval_dispatch_guard.py head", + } { + if !strings.Contains(workflow, want) { + t.Errorf("eval-dispatch workflow missing security contract %q", want) + } + } + if strings.Contains(workflow, "author_association") { + t.Error("eval-dispatch workflow must not authorize from author_association") + } + if _, err := os.Stat(filepath.Join(root, ".github", "eval-allowlist.txt")); err != nil { + t.Errorf("eval allowlist file missing on default branch: %v", err) + } +} + +func TestEvalDispatchRejectsLowRepositoryPermissions(t *testing.T) { + t.Parallel() + + for _, permission := range []string{"read", "triage", "none"} { + permission := permission + t.Run(permission, func(t *testing.T) { + t.Parallel() + output, err := runEvalDispatchGuard(t, "permission", `{"permission":"`+permission+`"}`, "COMMENTER=low-privilege-user") + if err == nil { + t.Fatalf("permission %q unexpectedly allowed; output=%s", permission, output) + } + if !strings.Contains(output, "does not have write, maintain, or admin permission") { + t.Fatalf("permission %q rejection = %q, want trusted-permission error", permission, output) + } + }) + } +} + +func TestEvalDispatchAllowsTrustedRepositoryPermissions(t *testing.T) { + t.Parallel() + + for _, permission := range []string{"write", "maintain", "admin"} { + permission := permission + t.Run(permission, func(t *testing.T) { + t.Parallel() + output, err := runEvalDispatchGuard(t, "permission", `{"permission":"`+permission+`"}`, "COMMENTER=maintainer") + if err != nil { + t.Fatalf("permission %q rejected: %v\n%s", permission, err, output) + } + }) + } +} + +func TestEvalDispatchRejectsChangedPRHead(t *testing.T) { + t.Parallel() + + const reviewedSHA = "1111111111111111111111111111111111111111" + const currentSHA = "2222222222222222222222222222222222222222" + pr := `{"number":934,"state":"open","head":{"sha":"` + currentSHA + `"}}` + output, err := runEvalDispatchGuard( + t, + "head", + pr, + "EXPECTED_PR_NUMBER=934", + "REVIEWED_SHA="+reviewedSHA, + ) + if err == nil { + t.Fatalf("changed PR head unexpectedly allowed; output=%s", output) + } + if !strings.Contains(output, "PR head changed after review") { + t.Fatalf("changed-head rejection = %q, want explicit stale-review error", output) + } +} + +func TestEvalDispatchAcceptsReviewedCurrentPRHead(t *testing.T) { + t.Parallel() + + const reviewedSHA = "1111111111111111111111111111111111111111" + pr := `{"number":934,"state":"open","head":{"sha":"` + reviewedSHA + `"}}` + output, err := runEvalDispatchGuard( + t, + "head", + pr, + "EXPECTED_PR_NUMBER=934", + "REVIEWED_SHA="+reviewedSHA, + ) + if err != nil { + t.Fatalf("reviewed current PR head rejected: %v\n%s", err, output) + } + if !strings.Contains(output, "head_sha="+reviewedSHA) { + t.Fatalf("head guard output = %q, want pinned head SHA", output) + } +} + +func TestEvalCommentRequiresExplicitReviewedSHA(t *testing.T) { + t.Parallel() + + const reviewedSHA = "1111111111111111111111111111111111111111" + output, err := runEvalCommentParser(t, "/eval drive sha="+reviewedSHA) + if err != nil { + t.Fatalf("explicit reviewed SHA rejected: %v\n%s", err, output) + } + for _, want := range []string{"products=drive", "reviewed_sha=" + reviewedSHA} { + if !strings.Contains(output, want) { + t.Errorf("parser output = %q, want %q", output, want) + } + } + + // sha 省略在解析层放行(空 reviewed_sha),由 guard 按评论者是否 PR 作者裁决 + output, err = runEvalCommentParser(t, "/eval drive") + if err != nil { + t.Fatalf("omitted reviewed SHA rejected at parse layer: %v\n%s", err, output) + } + if !strings.Contains(output, "reviewed_sha=\n") && !strings.HasSuffix(output, "reviewed_sha=") { + t.Fatalf("parser output = %q, want empty reviewed_sha passthrough", output) + } +} + +func TestEvalDispatchAutoPinsOwnPRHeadWhenShaOmitted(t *testing.T) { + t.Parallel() + + const currentSHA = "3333333333333333333333333333333333333333" + pr := `{"number":934,"state":"open","user":{"login":"Internal-Contributor"},"head":{"sha":"` + currentSHA + `"}}` + output, err := runEvalDispatchGuard( + t, + "head", + pr, + "EXPECTED_PR_NUMBER=934", + "REVIEWED_SHA=", + "COMMENTER=internal-contributor", + ) + if err != nil { + t.Fatalf("own-PR auto pin rejected: %v\n%s", err, output) + } + if !strings.Contains(output, "head_sha="+currentSHA) { + t.Fatalf("guard output = %q, want auto-pinned current head", output) + } +} + +func TestEvalDispatchRequiresShaForOthersPRs(t *testing.T) { + t.Parallel() + + const currentSHA = "3333333333333333333333333333333333333333" + pr := `{"number":934,"state":"open","user":{"login":"someone-else"},"head":{"sha":"` + currentSHA + `"}}` + output, err := runEvalDispatchGuard( + t, + "head", + pr, + "EXPECTED_PR_NUMBER=934", + "REVIEWED_SHA=", + "COMMENTER=internal-contributor", + ) + if err == nil { + t.Fatalf("other author's PR without sha unexpectedly allowed; output=%s", output) + } + if !strings.Contains(output, "requires an explicit reviewed sha") { + t.Fatalf("missing-sha rejection = %q, want explicit-sha requirement", output) + } +} + +func TestEvalDispatchAllowsAllowlistedAuthorOnOwnPR(t *testing.T) { + t.Parallel() + + allowlist := writeEvalAllowlist(t, "# 自助评测名单\nInternal-Contributor # 内部贡献者\n") + output, err := runEvalDispatchGuard( + t, + "permission", + `{"message":"Not Found"}`, + "COMMENTER=internal-contributor", + "PR_AUTHOR=Internal-Contributor", + "EVAL_ALLOWLIST_PATH="+allowlist, + ) + if err != nil { + t.Fatalf("allowlisted author rejected on own PR: %v\n%s", err, output) + } + if !strings.Contains(output, "authorized=allowlisted-author") { + t.Fatalf("guard output = %q, want allowlisted-author authorization", output) + } +} + +func TestEvalDispatchRejectsAllowlistedUserOnOthersPR(t *testing.T) { + t.Parallel() + + allowlist := writeEvalAllowlist(t, "internal-contributor\n") + output, err := runEvalDispatchGuard( + t, + "permission", + `{"message":"Not Found"}`, + "COMMENTER=internal-contributor", + "PR_AUTHOR=someone-else", + "EVAL_ALLOWLIST_PATH="+allowlist, + ) + if err == nil { + t.Fatalf("allowlisted user unexpectedly dispatched another author's PR; output=%s", output) + } + if !strings.Contains(output, "self-service only") { + t.Fatalf("cross-PR rejection = %q, want self-service-only error", output) + } +} + +func TestEvalDispatchRejectsUnlistedCommenterWithoutWrite(t *testing.T) { + t.Parallel() + + allowlist := writeEvalAllowlist(t, "# 空名单\n") + output, err := runEvalDispatchGuard( + t, + "permission", + `{"permission":"read"}`, + "COMMENTER=stranger", + "PR_AUTHOR=stranger", + "EVAL_ALLOWLIST_PATH="+allowlist, + ) + if err == nil { + t.Fatalf("unlisted commenter unexpectedly allowed; output=%s", output) + } + if !strings.Contains(output, "not an allowlisted PR author") { + t.Fatalf("unlisted rejection = %q, want allowlist-aware error", output) + } +} + +func writeEvalAllowlist(t *testing.T, content string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "eval-allowlist.txt") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write allowlist fixture: %v", err) + } + return path +} + +func runEvalDispatchGuard(t *testing.T, mode, input string, env ...string) (string, error) { + t.Helper() + + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + cmd := exec.Command("python3", filepath.Join(root, "scripts", "ci", "eval_dispatch_guard.py"), mode) + cmd.Stdin = strings.NewReader(input) + cmd.Env = append(os.Environ(), env...) + output, runErr := cmd.CombinedOutput() + return string(output), runErr +} + +func runEvalCommentParser(t *testing.T, comment string) (string, error) { + t.Helper() + + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + outputPath := filepath.Join(t.TempDir(), "github-output") + cmd := exec.Command("python3", filepath.Join(root, "scripts", "ci", "eval_comment_parse.py")) + cmd.Env = append( + os.Environ(), + "COMMENT_BODY="+comment, + "GITHUB_OUTPUT="+outputPath, + ) + combined, runErr := cmd.CombinedOutput() + fileOutput, readErr := os.ReadFile(outputPath) + if readErr != nil && !os.IsNotExist(readErr) { + t.Fatalf("read parser GitHub output: %v", readErr) + } + return string(combined) + string(fileOutput), runErr +}