Compare commits

...
Author SHA1 Message Date
修雨 6b50cc41c5 ci(release): add dispatch repair-gitee job to mirror an existing release
The push-triggered mirror-gitee-release job consumes the same run's
finalized-release-dist artifact, so it cannot mirror a tag that was
already published — including one delivered by a recovery dispatch such
as v1.0.53-beta.3. Add a workflow_dispatch repair-gitee job (input
mirror_gitee_version) that re-derives the asset set from the immutable
GitHub Release, verifies it byte-for-byte via checksums, and runs
sync-to-gitee.sh. Guarded to the official repo + default branch and
gated by the existing Gitee secrets.
2026-07-19 21:51:35 +08:00
修雨 11e50662f9 Merge pull request #683 from DingTalk-Real-AI/codex/fix-event-bus-shutdown-race
fix(event): serialize bus shutdown with accept loop
2026-07-19 21:39:53 +08:00
修雨 29abdb6e79 fix(event): serialize bus shutdown with accept loop
Wait for the accept loop to stop before waiting for connection handlers, and track accepted connections before publishing handlers. This removes the WaitGroup Add/Wait race caught by PR #667 CI and follows up the event bus introduced in #589.
2026-07-19 21:21:16 +08:00
修雨 6196e2565e Merge pull request #678 from DingTalk-Real-AI/fix/release-draft-asset-verify
fix(release): bind draft publication to release ID
2026-07-19 19:52:09 +08:00
修雨 609d56305e fix(release): bind draft publication to release ID 2026-07-19 12:22:44 +08:00
修雨 e69a1084a7 Merge pull request #675 from DingTalk-Real-AI/codex/fix-release-skip-propagation
fix(release): prevent skipped publication false greens
2026-07-18 12:11:37 +08:00
修雨 978ee6e636 fix(release): fail closed on skipped publication 2026-07-18 11:34:34 +08:00
修雨 987c63d99c Merge pull request #649 from PeterGuy326/codex/fast-quality-release
fix(release): add fast guarded release and recovery paths
2026-07-17 17:35:30 +08:00
修雨 e565746fb7 Merge remote-tracking branch 'origin/main' into codex/fast-quality-release
# Conflicts:
#	CHANGELOG.md
2026-07-17 17:19:02 +08:00
修雨 4f76d7cb4c fix(release): verify release token capabilities 2026-07-17 17:18:12 +08:00
修雨 e94f230236 Merge pull request #668 from DingTalk-Real-AI/release/changelog-v1.0.53-beta.4
docs(changelog): seal v1.0.53-beta.4
2026-07-17 17:08:20 +08:00
修雨 5242a0e1b1 docs(changelog): promote Unreleased into v1.0.53-beta.4
Seal the accumulated personal IM event subscription expansion and the
flattened event consume output (#651) into a dated beta.4 section.
2026-07-17 16:53:17 +08:00
修雨 c3e57b874b fix(ci): satisfy release workflow shellcheck 2026-07-17 16:13:16 +08:00
修雨 fa558372d2 fix(release): add fast guarded recovery path 2026-07-17 16:13:15 +08:00
修雨 ec9ae33a43 Merge pull request #651 from wxianfeng/feat/dws-event-im-2phase
feat(event): expand personal IM events and flatten output
2026-07-17 16:04:50 +08:00
修雨 2b49a2f365 Merge pull request #662 from LastdianXuan/agent/fix-eval-confirmed-bugs
fix: address confirmed CLI contract issues from v1.0.53 evaluation
2026-07-17 15:46:40 +08:00
修雨 ae9b14e536 Merge main into feat/dws-event-im-2phase 2026-07-17 15:46:38 +08:00
修雨 996c4ab250 fix: propagate structured output write failures 2026-07-17 15:04:13 +08:00
修雨 cf36ccb46e Merge remote-tracking branch 'origin/main' into codex/pr662-current 2026-07-17 14:56:36 +08:00
修雨 361115956f Merge pull request #648 from LastdianXuan/agent/fix-chat-update-icon-media-id
fix: accept uploaded media IDs for group icons
2026-07-17 14:50:51 +08:00
张卓澎 91bd7c7802 fix: emit structured audit verification output 2026-07-17 13:44:18 +08:00
张卓澎 5a8376ac0f fix: keep JSON command output machine-readable 2026-07-17 13:44:18 +08:00
张卓澎 31c984e18c fix: use MCP group ID key for message lists 2026-07-17 13:44:18 +08:00
修雨 69c0eb1a49 Merge remote-tracking branch 'origin/main' into codex/pr648-current 2026-07-17 12:25:43 +08:00
张卓澎 82e98d98a3 test: cover group icon validation on all platforms 2026-07-17 12:15:22 +08:00
张卓澎 8a7e1c7be7 fix: accept uploaded media IDs for group icons 2026-07-17 12:01:25 +08:00
wxianfeng f59be6c19a fix(event): address PR review gates 2026-07-17 11:57:27 +08:00
wxianfeng c7c9a6f926 Merge remote-tracking branch 'upstream/main' into feat/dws-event-im-2phase
# Conflicts:
#	CHANGELOG.md
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog.json
2026-07-16 23:20:35 +08:00
wxianfeng a0224e1cbd fix(event): refine schema contracts and metadata 2026-07-16 17:45:03 +08:00
wxianfeng cd22cfb530 chore(event): switch personal events to production 2026-07-16 15:04:04 +08:00
wxianfeng adc528c206 Merge remote-tracking branch 'upstream/main' into feat/dws-event-im-2phase
# Conflicts:
#	.github/badges/coverage.svg
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog.json
#	internal/event/consume/formatter.go
2026-07-16 14:27:39 +08:00
wxianfeng a2f1e79603 Merge remote-tracking branch 'upstream/main' into feat/dws-event-im-2phase
# Conflicts:
#	internal/cli/cobra_schema_test.go
#	skills/mono/references/products/event.md
#	skills/multi/dingtalk-event/SKILL.md
#	skills/multi/dingtalk-event/references/event-im.md
2026-07-15 20:36:56 +08:00
wxianfeng 1adb4bc681 feat(event): support openDingtalkId subscription targets 2026-07-14 20:58:13 +08:00
wxianfeng 723c577484 Merge remote-tracking branch 'upstream/main' into feat/dws-event-im-2phase
# Conflicts:
#	internal/app/event_personal_command.go
#	internal/event/consume/run.go
#	skills/mono/references/products/event.md
#	skills/multi/dingtalk-event/SKILL.md
#	skills/multi/dingtalk-event/references/event-im.md
2026-07-14 16:15:13 +08:00
wxianfeng 766930f6e7 feat(event): flatten personal event output 2026-07-14 15:39:21 +08:00
wxianfeng 2e3311c955 feat(event): expose sender message event 2026-07-13 15:47:27 +08:00
wxianfeng 1b4bb6b498 refactor(event): rename emotion events to reaction 2026-07-13 15:06:03 +08:00
wxianfeng 368e439280 chore(event): default personal events to pre-release 2026-07-13 11:36:41 +08:00
wxianfeng 8965fd2707 feat(event): add read recall and emotion events 2026-07-13 11:19:27 +08:00
wxianfeng c0a7ad88a4 Merge branch 'main' of github.com:wxianfeng/dingtalk-workspace-cli 2026-07-13 10:33:45 +08:00
wxianfeng b62b1848aa Merge remote-tracking branch 'upstream/main' 2026-07-13 10:30:52 +08:00
github-actions[bot] 5dd7f9abd3 chore: update coverage badge [skip ci] 2026-07-13 02:11:20 +00:00
wxianfeng eefee3c063 Merge branch 'main' of github.com:wxianfeng/dingtalk-workspace-cli 2026-07-13 10:08:11 +08:00
github-actions[bot] 6f5d17335b chore: update coverage badge [skip ci] 2026-06-04 10:04:00 +00:00
74 changed files with 7119 additions and 899 deletions
File diff suppressed because it is too large Load Diff
+22
View File
@@ -6,6 +6,28 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### Changed
- **Fast guarded beta and stable releases** — successful local release checks now leave a six-hour proof bound to the exact version, commit, repository identity, remote `main`, and stable baseline, so the subsequent guarded `--publish` invocation revalidates authority without repeating tests and packaging. A default-branch governance smoke uses the same dedicated immutable-release credential as the tag workflow before any tag is allocated.
- **Protected existing-tag recovery** — `dws-release recover <version>` can resume a failed, unpublished annotated tag through the normal contract, build, Developer ID signing, immutable GitHub Release, Homebrew, npm, and OSS jobs. Recovery requires the exact tag object, peeled commit, failed tag-push run, typed version confirmation, and the protected `release-recovery` environment; successful runs are accepted as future beta/stable delivery evidence.
### Fixed
- **Tag-push GitHub Release publication** — Draft publication now locks one GitHub Release database ID, verifies its exact tag, channel, notes, recovery marker, asset set, and uploaded bytes, then publishes and rechecks that same ID as immutable. Recovery runs use the trusted default-branch release helpers instead of the sealed tag's historical scripts, fixing the Draft-only `GET /releases/tags/{tag}` 404 without allowing the release identity to drift during recovery.
- **Release preflight reliability** — source-mode installer tests now use isolated temporary checkouts and HOME directories instead of overwriting and deleting the real repository `dws` binary, release preflight explicitly rebuilds before policy checks, and the full-suite runner gives the growing script package a non-flaky five-minute per-suite budget.
## [1.0.53-beta.4] - 2026-07-17
This beta validates the expanded personal IM event subscriptions and the flattened `event consume` structured output introduced after v1.0.53-beta.3.
### Added
- **Expanded personal IM event subscriptions** (#651) — adds one-to-one and group events for message read receipts, recalls, and reactions; publishes the specified-sender receive event; and lets one-to-one/sender subscriptions target either a staff `--user` or an `--open-dingtalk-id`. Event Schema now exposes these alternatives through machine-readable parameter constraints.
### Changed
- **Personal event structured output is now flat** (#651) — `event consume` projects NDJSON/JSON/pretty/compact output into event-specific top-level DTOs, so consumers read fields such as `content`, `sender`, and `conversation_id` directly instead of parsing `.data | fromjson`. This is a breaking change for scripts using the former transport envelope; the original server payload remains available through `-f raw`, while `--debug-raw-events` preserves the full diagnostic envelope.
## [1.0.53-beta.3] - 2026-07-17
This beta validates multi-account profile support and the post-v1.0.53-beta.2 compatibility fixes for Windows portable authentication, IM shortcuts, and Aitable import uploads.
+1 -1
View File
@@ -45,7 +45,7 @@ rebuild:
@./scripts/dev/build.sh
test:
@./test/scripts/run_all_tests.sh
@./test/scripts/run_all_tests.sh --timeout 5m
lint:
@./scripts/dev/lint.sh
+5
View File
@@ -495,6 +495,9 @@ dws event consume user_im_message_receive_at -f ndjson
# Listen for one-to-one messages with a specified user
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
# Listen by openDingtalkId (external contact, bot, or cross-organization identity)
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> -f ndjson
# Listen for messages in a specified group
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
@@ -503,6 +506,8 @@ dws event status
dws event stop <subscribe_id>
```
For one-to-one and specified-sender events, use exactly one target identity: `--user` for an internal `userId`, or `--open-dingtalk-id` for an `openDingtalkId`. The CLI does not infer or convert between these identity types.
| Feature | Details |
|---------|---------|
| Managed lifecycle | `consume` creates or reuses the personal subscription; `stop` cancels it and cleans local state |
+5
View File
@@ -489,6 +489,9 @@ dws event consume user_im_message_receive_at -f ndjson
# 监听与指定用户的单聊消息
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
# 使用 openDingtalkId 监听外部联系人、机器人或跨组织身份
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> -f ndjson
# 监听指定群的消息
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
@@ -497,6 +500,8 @@ dws event status
dws event stop <subscribe_id>
```
单聊和指定发送人事件必须且只能选择一种目标身份:企业内部 `userId` 使用 `--user`,`openDingtalkId` 使用 `--open-dingtalk-id`。CLI 不会自动猜测或转换身份类型。
| 特性 | 说明 |
|------|------|
| 自动编排 | `consume` 创建或复用个人订阅,`stop` 取消订阅并清理本地状态 |
+34 -6
View File
@@ -65,21 +65,49 @@ git diff --check
## Homebrew Formula PR Automation
Official tag releases require the repository Actions secret
`HOMEBREW_PR_TOKEN`. The `DingTalk-Real-AI` organization currently does not
allow fine-grained personal access tokens to target this repository, so use a
classic personal access token owned by a maintainer or release-bot account with
`HOMEBREW_PR_TOKEN`. Prefer a fine-grained personal access token owned by a
maintainer or release-bot account, limited to this repository with
`Contents: write` and `Pull requests: write`. If organization policy prevents
that account from targeting the repository, use a dedicated classic token with
only the `public_repo` scope. Do not reuse a broad developer token.
Store the non-expiring token as the `HOMEBREW_PR_TOKEN` repository Actions
secret. Replace it immediately if it is exposed, its owner loses repository
access, or the release-bot ownership changes. The Release workflow uses this
Store the dedicated token as the `HOMEBREW_PR_TOKEN` repository Actions secret
and rotate it before its configured expiration. Replace it immediately if it is
exposed, its owner loses repository access, or the release-bot ownership
changes. The Release workflow uses this
dedicated token only to push an `automation/homebrew-*` branch and open the
stable or beta Formula PR. It does not push Formula changes directly to `main`.
The default-branch governance preflight and every tag contract authenticate the
token before publication, reject over-scoped classic tokens, confirm its
identity, and run a controlled write canary. The canary pushes a unique
`automation/homebrew-token-canary-*` branch with a `[skip ci]` commit, creates a
draft PR, closes it, and deletes the branch with the same token. This proves both
Contents and Pull requests write access before publication without merging
anything. The gate also rejects reuse of `RELEASE_GOVERNANCE_TOKEN`.
No maintainer environment variable is required when creating a tag. Using the
built-in `GITHUB_TOKEN` is insufficient because organization policy prevents
Actions from creating pull requests, and its generated PR events may require
separate workflow approval.
## Release Governance and Recovery
Store `RELEASE_GOVERNANCE_TOKEN` as a dedicated Actions secret with only
repository `Administration: read`. The immutable-releases REST endpoint is an
administration setting and cannot be read by the workflow's built-in
`GITHUB_TOKEN`. Both the default-branch governance preflight and the tag
contract use this same credential so a missing or expired identity is detected
before an irreversible tag is created.
Create a protected `release-recovery` environment limited to protected
branches, with a required reviewer, self-review disabled, and administrator
bypass disabled. The workflow reads the environment through the GitHub API and
fails closed unless the required-reviewer, prevent-self-review, and protected-
branch rules are present.
Recovery is restricted to an existing annotated tag whose exact tag object,
commit, and failed tag-push run all match; it then reuses the normal release
jobs. Do not put publication secrets in temporary branches or create ad-hoc
recovery workflows.
## Handoff Checklist
Before handoff, include:
+5 -8
View File
@@ -1,10 +1,8 @@
# Event consume — AI subprocess contract
Aligns `dws event consume` with the "AI subprocess contract" that
`lark-cli event consume` exposes, so any orchestrator (Claude Code's
Monitor, a bash bridge, systemd, an agent plugin) can drive it with zero
ambiguity: know when it is ready, stop it cleanly, and machine-read why it
exited.
Defines the stable `dws event consume` subprocess contract so an
orchestrator can determine when the consumer is ready, stop it cleanly,
and machine-read why it exited.
Scope of this branch: the four **contract** items below. Reconnect
resilience (keeping the stream alive across a transient upstream drop) is
@@ -16,7 +14,6 @@ tracked separately and intentionally out of scope here.
- `--duration D` — wall-clock budget (exit 0). Kept as `--duration`, NOT
aliased to `--timeout`: the global `--timeout` is the HTTP request
timeout (int seconds) and would collide (different type and meaning).
Docs note the lark-cli name difference.
- Bus idle-shutdown fires only with **zero** consumers, so a connected
consumer is never idle-killed.
- SIGINT/SIGTERM already cancel the run context and return cleanly.
@@ -28,7 +25,7 @@ tracked separately and intentionally out of scope here.
On connect, emit a fixed stderr line **before** any stdout event:
```
[event] ready event_key=<key> bus_pid=<pid>
[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>
```
Parents block on stderr until this line, then read stdout. Suppressed
@@ -75,7 +72,7 @@ or runtime failure (permissions, network, params) = non-zero, with no
### 4. Cleanup on exit (no `kill -9`)
Ownership-based, matching lark-cli:
Ownership-based cleanup:
- If this run **created** the subscription (no `--subscribe-id`), a clean
exit (SIGTERM / SIGINT / stdin-EOF / limit / timeout) **unsubscribes**
it server-side and sends Bye.
+24 -3
View File
@@ -45,13 +45,13 @@ dws-release v1.2.3-beta.1
dws-release v1.2.3-beta.1
```
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。通过后发布:
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查 `CI Gate` 和 immutable releases。通过后会在当前 Git worktree 的私有 Git 状态目录写入一个有效期六小时的证明,绑定版本、精确 commit、发布仓库、beta/stable 基线和远端 `main`:
```bash
dws-release v1.2.3-beta.1 --publish
```
命令会在所有预检完成后要求再次输入完整版本号。统一入口不提供跳过确认的参数。
若源码、版本、远端身份和 stable 基线均未变化,`--publish` 会复用该证明,只执行远端契约、发布身份和最终治理复核,不再重复测试与打包。也可以直接运行 `--publish`;没有可复用证明时只会完整执行一次预检。命令在封 tag 前仍要求再次输入完整版本号,统一入口不提供跳过确认的参数。
## 正式发布
@@ -99,15 +99,36 @@ npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_versio
OSS/Gitee 分发失败时直接重跑该 tag 的 `Publish npm and mirrors` failed job;各步会复用 immutable GitHub 资产并保持 channel 单调。独立 Gitee release workflow 和本地直发脚本已停用,避免绕开 publication queue 或用重新构建的不同字节覆盖镜像。
## 既有 tag 的紧急恢复
tag push 已成功、但 Release workflow 失败且 GitHub Release 尚未公开时,不要新建临时 workflow、移动 tag 或跳过门禁。在最新且干净的 `main` worktree 运行:
```bash
dws-release recover v1.2.3-beta.1
```
命令会自动解析 annotated tag object、peeled commit 和最近一次匹配的失败 tag-push run;也可以用 `--failed-run <run-id>` 精确指定。确认完整版本号后,它从默认分支触发受保护的恢复模式并等待完成。恢复模式必须满足:
- 输入精确绑定原 annotated tag object、commit 和失败的 exact-tag `Release` run;commit 必须仍在 `main` 历史中。
- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本只能走对应的 channel repair,不能全量重建。
- `release-recovery` environment 必须限制为受保护分支、配置至少一名 required reviewer,并禁止自审;workflow 会通过 API 复核这些设置,未配置时 fail closed。
- 恢复复用正常的 contract、构建、Developer ID 签名、资产校验、immutable 发布、Homebrew、npm 和 OSS jobs,不存在 recovery 专用 publisher 或门禁跳过。
- 如果 GitHub Release 已在 recovery 中封存、后续 Homebrew/npm 校验发生瞬时失败,只重跑该 run 的 failed jobs;流水线仅在隐藏 run marker、tag object、commit 和 finalized artifact 字节全部精确一致时复用公开 Release。
成功的默认分支恢复 run 会成为后续 beta → stable 和 stable baseline 验证的可审计交付证据;历史临时分支恢复仍只接受 reviewed manifest 中的固定证据。
OSS 的 `latest.txt` / `beta.txt` 当前是镜像频道元数据;仓库内安装器仍从 GitHub/Gitee 解析版本,不能把 OSS pointer 当成已接入的安装通道。
Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 macOS 架构真实安装,但 Release workflow 不发布 tap,CI 生成的单主机公式也不应当作 Darwin 双架构正式交付。正式自动交付范围是 GitHub Release、npm、OSS,以及显式开启时的 Gitee fallback;Homebrew 双架构 tap 发布需另立需求。
## 平台治理前置
仓库管理员还需要在 GitHub 平台配置两项不可由脚本替代的规则:
仓库管理员还需要在 GitHub 平台配置以下不可由脚本替代的规则:
- `main` 必须要求精确的 `CI Gate`;tag workflow 也会通过 Checks API 再确认该封板 SHA 已通过。
- 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。
- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和 tag workflow 都使用这一个身份进行 fail-closed 验证。
- 单独配置 `HOMEBREW_PR_TOKEN`,优先使用仅授权本仓库且具备 `Contents: write`、`Pull requests: write` 的 fine-grained PAT;若组织策略不允许该账号使用 fine-grained PAT,则回退到仅带 `public_repo` scope 的专用 classic PAT。治理预检和 tag contract 会验证 token 身份、classic scope,并用 `[skip ci]` 临时分支和 draft PR 完成真实写权限 canary,随后立即关闭 PR、删除分支;任何清理失败都会 fail closed。门禁也会拒绝与治理 token 复用。
- 创建 `release-recovery` environment,只允许受保护分支,设置 required reviewer、禁止自审并关闭管理员绕过。workflow 会读取 environment 的 required-reviewer、prevent-self-review 和 protected-branch 规则;规则缺失时紧急恢复会失败,正常 beta/stable tag 发布不受影响。
immutable releases 或 `CI Gate` 缺失时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
+24 -3
View File
@@ -13,6 +13,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/spf13/cobra"
)
@@ -117,9 +118,29 @@ func newAuditVerifyCommand() *cobra.Command {
}
}
valid, brokenAt, err := auditVerify(target)
if err != nil {
return fmt.Errorf("校验失败: %w", err)
valid, brokenAt, verifyErr := auditVerify(target)
if output.ResolveFormat(cmd, output.FormatTable) == output.FormatJSON {
if verifyErr != nil && brokenAt == 0 {
return fmt.Errorf("校验失败: %w", verifyErr)
}
payload := map[string]any{
"valid": valid,
"file": target,
"brokenAt": brokenAt,
}
if verifyErr != nil {
payload["reason"] = verifyErr.Error()
}
if err := output.WriteCommandPayload(cmd, payload, output.FormatTable); err != nil {
return err
}
if !valid {
auditExit(1)
}
return nil
}
if verifyErr != nil {
return fmt.Errorf("校验失败: %w", verifyErr)
}
if valid {
fmt.Printf("✓ %s 哈希链完整(全部通过)\n", filepath.Base(target))
@@ -0,0 +1,65 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package app
import (
"bytes"
"encoding/json"
"errors"
"testing"
"github.com/spf13/cobra"
)
func executeAuditVerifyJSON(t *testing.T, verify func(string) (bool, int, error)) (map[string]any, int, error) {
t.Helper()
previousVerify, previousExit := auditVerify, auditExit
t.Cleanup(func() {
auditVerify, auditExit = previousVerify, previousExit
})
auditVerify = verify
exitCode := 0
auditExit = func(code int) { exitCode = code }
root := &cobra.Command{Use: "dws"}
root.SilenceErrors = true
root.SilenceUsage = true
root.PersistentFlags().String("format", "json", "output format")
root.AddCommand(newAuditVerifyCommand())
var stdout bytes.Buffer
root.SetOut(&stdout)
root.SetArgs([]string{"verify", "--file", "/tmp/audit.jsonl"})
err := root.Execute()
var payload map[string]any
if decodeErr := json.Unmarshal(stdout.Bytes(), &payload); decodeErr != nil {
t.Fatalf("audit verify stdout must be one JSON document: %v\n%s", decodeErr, stdout.String())
}
return payload, exitCode, err
}
func TestCrossPlatformCoverageAuditVerifyJSONOutputIsSingleDocument(t *testing.T) {
payload, exitCode, err := executeAuditVerifyJSON(t, func(string) (bool, int, error) {
return true, 0, nil
})
if err != nil || exitCode != 0 {
t.Fatalf("audit verify returned err=%v exit=%d", err, exitCode)
}
if payload["valid"] != true || payload["file"] != "/tmp/audit.jsonl" || payload["brokenAt"] != float64(0) {
t.Fatalf("unexpected audit payload: %#v", payload)
}
}
func TestCrossPlatformCoverageAuditVerifyBrokenJSONIncludesReasonBeforeExit(t *testing.T) {
payload, exitCode, err := executeAuditVerifyJSON(t, func(string) (bool, int, error) {
return false, 3, errors.New("prev_hash mismatch")
})
if err != nil || exitCode != 1 {
t.Fatalf("broken audit verify returned err=%v exit=%d", err, exitCode)
}
if payload["valid"] != false || payload["brokenAt"] != float64(3) || payload["reason"] != "prev_hash mismatch" {
t.Fatalf("unexpected broken audit payload: %#v", payload)
}
}
+3 -6
View File
@@ -1475,14 +1475,11 @@ func TestCrossPlatformCoveragePersonalEventPureCoverage(t *testing.T) {
if err := renderPersonalSchema(io.Discard, def, "yaml"); err == nil {
t.Fatal("unsupported schema format succeeded")
}
for _, key := range []string{"", "unknown", personal.EventMention} {
for _, key := range []string{"", "unknown", personal.EventMention, personal.EventFromUser} {
if err := ensurePublicPersonalEvent(key); err != nil {
t.Fatalf("public event %q: %v", key, err)
}
}
if err := ensurePublicPersonalEvent(personal.EventFromUser); err == nil {
t.Fatal("private event accepted")
}
var cfg consume.Config
applyPersonalConsumeFilters(nil, personalConsumeOptions{}, "", "")
@@ -1617,7 +1614,7 @@ func TestCrossPlatformCoveragePersonalSubscriptionAndSourceCoverage(t *testing.T
{"lookup", personalConsumeOptions{SubscribeID: "existing"}, true},
{"create", personalConsumeOptions{EventKey: personal.EventMention, Name: "name", FilterJSON: `{"field":"content","op":"eq","value":"x"}`, QueryCSV: "a,b", TTL: time.Minute}, true},
{"missing", personalConsumeOptions{}, false},
{"private", personalConsumeOptions{EventKey: personal.EventFromUser, UserID: "u"}, false},
{"sender", personalConsumeOptions{EventKey: personal.EventFromUser, UserID: "u"}, true},
{"bad-rule", personalConsumeOptions{EventKey: personal.EventMention, Rule: "other"}, false},
{"bad-filter", personalConsumeOptions{EventKey: personal.EventMention, FilterJSON: "{"}, false},
} {
@@ -1631,7 +1628,7 @@ func TestCrossPlatformCoveragePersonalSubscriptionAndSourceCoverage(t *testing.T
}
})
}
if createCount != 1 {
if createCount != 2 {
t.Fatalf("create count = %d", createCount)
}
+4 -1
View File
@@ -174,6 +174,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
"ttl",
"ephemeral",
"user",
"open-dingtalk-id",
"group",
"personal-event-base-url",
); err != nil {
@@ -312,7 +313,9 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
"或从外部先用 dws event stop <subscribe_id> --dry-run 预览、确认后加 --yes(会一并退订);"+
"请勿 kill -9(会跳过退订、泄漏服务端订阅)")
f.StringVar(&personalOpts.UserID, "user", "",
"个人单聊对端 userId")
"单聊对端或指定发送人的 userId(与 --open-dingtalk-id 二选一)")
f.StringVar(&personalOpts.OpenDingTalkID, "open-dingtalk-id", "",
"单聊对端或指定发送人的 openDingtalkId(与 --user 二选一)")
f.StringVar(&personalOpts.GroupID, "group", "",
"group 规则:openConversationId")
f.StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "",
+53 -21
View File
@@ -37,6 +37,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
@@ -69,6 +70,7 @@ type personalConsumeOptions struct {
TTL time.Duration
Ephemeral bool
UserID string
OpenDingTalkID string
GroupID string
ControlBaseURL string
StreamTicketMode string
@@ -226,8 +228,14 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
if fellback && !opts.Common.Quiet {
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
}
projector := personalEventProjector(opts.DebugRawEvents)
if opts.Common.DryRun {
if strings.TrimSpace(opts.SubscribeID) == "" {
if err := validatePersonalSubscriptionOptions(opts); err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
}
cfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
@@ -240,6 +248,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
Format: normalised,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Projector: projector,
Stderr: c.ErrOrStderr(),
Quiet: opts.Common.Quiet,
Foreground: opts.Common.Foreground,
@@ -272,8 +281,8 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
_ = personalDeleteSubscription(client, context.Background(), sub.SubscribeID)
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
}
// Ownership-based cleanup (AI-subprocess contract, aligned with
// lark-cli): a subscription this run CREATED is unsubscribed on exit
// Ownership-based cleanup: a subscription this run CREATED is
// unsubscribed on exit
// (any exit — SIGTERM / stdin-EOF / limit / timeout / error), so nothing
// leaks server-side. A subscription REUSED via --subscribe-id is left
// intact — the caller owns its lifecycle. --ephemeral forces cleanup
@@ -284,22 +293,24 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
}
cfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
EventKey: eventKey,
Format: normalised,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Stdout: c.OutOrStdout(),
Stderr: c.ErrOrStderr(),
Quiet: opts.Common.Quiet,
Foreground: opts.Common.Foreground,
Force: opts.Common.Force,
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
EventKey: eventKey,
Format: normalised,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Projector: projector,
ReadySubscribeID: sub.SubscribeID,
Stdout: c.OutOrStdout(),
Stderr: c.ErrOrStderr(),
Quiet: opts.Common.Quiet,
Foreground: opts.Common.Foreground,
Force: opts.Common.Force,
}
// Arm the stdin-EOF shutdown watcher only for a pipe-style, unbounded
// run (see shouldWatchStdinEOF).
@@ -354,6 +365,13 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
return err
}
func personalEventProjector(debugRawEvents bool) consume.Projector {
if debugRawEvents {
return func(ev transport.Event) (any, error) { return ev, nil }
}
return personal.ProjectOutput
}
func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOptions, subscribeID, eventKey string) {
if cfg == nil {
return
@@ -369,6 +387,19 @@ func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOption
cfg.SubscribeID = strings.TrimSpace(subscribeID)
}
func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
if _, _, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
RuleType: opts.Rule,
UserID: opts.UserID,
OpenDingTalkID: opts.OpenDingTalkID,
GroupID: opts.GroupID,
}); err != nil {
return err
}
_, _, err := personal.BuildFilter(opts.FilterJSON, opts.QueryCSV)
return err
}
func ensurePersonalSubscription(ctx context.Context, client *personal.Client, identity personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
if strings.TrimSpace(opts.SubscribeID) != "" {
sub, err := personalGetSubscription(client, ctx, opts.SubscribeID)
@@ -398,9 +429,10 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id
return nil, "", "", err
}
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
RuleType: opts.Rule,
UserID: opts.UserID,
GroupID: opts.GroupID,
RuleType: opts.Rule,
UserID: opts.UserID,
OpenDingTalkID: opts.OpenDingTalkID,
GroupID: opts.GroupID,
})
if err != nil {
return nil, "", "", err
+32 -3
View File
@@ -19,10 +19,11 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
func TestApplyPersonalConsumeFiltersDebugRawEvents(t *testing.T) {
cfg := consume.Config{}
cfg := consume.Config{EventKey: personal.EventSingleChat, ReadySubscribeID: "sub-1"}
opts := personalConsumeOptions{
DebugRawEvents: true,
Common: commonConsumeOptions{
@@ -34,6 +35,9 @@ func TestApplyPersonalConsumeFiltersDebugRawEvents(t *testing.T) {
if cfg.EventTypes != nil || cfg.Filter != "" || cfg.SubscribeID != "" {
t.Fatalf("raw debug filters = eventTypes=%#v filter=%q subscribeID=%q, want catch-all", cfg.EventTypes, cfg.Filter, cfg.SubscribeID)
}
if cfg.EventKey != personal.EventSingleChat || cfg.ReadySubscribeID != "sub-1" {
t.Fatalf("raw debug cleared ready identity: eventKey=%q subscribeID=%q", cfg.EventKey, cfg.ReadySubscribeID)
}
}
func TestApplyPersonalConsumeFiltersDefault(t *testing.T) {
@@ -48,6 +52,28 @@ func TestApplyPersonalConsumeFiltersDefault(t *testing.T) {
}
}
func TestPersonalEventProjectorUsesRawEnvelopeForDebug(t *testing.T) {
if personalEventProjector(false) == nil {
t.Fatal("normal personal consume projector = nil")
}
projector := personalEventProjector(true)
if projector == nil {
t.Fatal("debug raw personal consume projector = nil")
}
ev := transport.Event{
EventID: "raw-event",
Data: `{"payload":{"uid":100001,"bizid":"internal-bizid"}}`,
Headers: map[string]string{"TOPIC": "raw"},
}
projected, err := projector(ev)
if err != nil {
t.Fatal(err)
}
if got, ok := projected.(transport.Event); !ok || got.EventID != ev.EventID || got.Data != ev.Data || got.Headers["TOPIC"] != "raw" {
t.Fatalf("debug raw projection = %#v", projected)
}
}
func TestEventConsumeDebugRawEventsRequiresUserMode(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
@@ -72,7 +98,7 @@ func TestEventConsumeAsAppRejectedBeforeEventKeyValidation(t *testing.T) {
func TestEventConsumePersonalParamSpecFlags(t *testing.T) {
cmd := newEventConsumeCommand()
for _, name := range []string{"user", "group", "query"} {
for _, name := range []string{"user", "open-dingtalk-id", "group", "query"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("flag --%s is not registered", name)
}
@@ -84,6 +110,7 @@ func TestEventConsumePersonalParamSpecFlags(t *testing.T) {
"sender-union-id",
"open-conversation-id",
"keyword",
"odid",
} {
if cmd.Flags().Lookup(name) != nil {
t.Fatalf("retired flag --%s is still registered", name)
@@ -99,6 +126,7 @@ func TestEventConsumeRetiredPersonalFlagsAreUnknown(t *testing.T) {
"sender-union-id",
"open-conversation-id",
"keyword",
"odid",
} {
t.Run(name, func(t *testing.T) {
cmd := newEventConsumeCommand()
@@ -115,7 +143,8 @@ func TestEventConsumeRetiredPersonalFlagsAreUnknown(t *testing.T) {
func TestEventConsumeAsAppRejectedBeforePersonalParamSpecFlags(t *testing.T) {
for _, args := range [][]string{
{"--as", "app", "--user", "507971"},
{"--as", "app", "--user", "test-user-001"},
{"--as", "app", "--open-dingtalk-id", "open-user-1"},
{"--as", "app", "--group", "cid"},
{"--as", "app", "--query", "报警"},
} {
@@ -57,8 +57,8 @@ func TestCrossPlatformCoveragePersonalEventRemainingSchemaAndSubscriptionCoverag
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{EventKey: personal.EventFromUser}, nil
}
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err == nil {
t.Fatal("private subscription event succeeded")
if _, key, rule, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err != nil || key != personal.EventFromUser || rule != "sender" {
t.Fatalf("sender subscription = %q %q, %v", key, rule, err)
}
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{EventKey: personal.EventMention}, nil
+229 -37
View File
@@ -18,7 +18,9 @@ import (
"encoding/json"
"strings"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/spf13/cobra"
)
@@ -43,8 +45,18 @@ func TestPersonalEventListHidesSchemaIDs(t *testing.T) {
}
got := out.String()
assertPersonalOutputHidesSchemaIDs(t, got)
if strings.Contains(got, personal.EventFromUser) {
t.Fatalf("list output exposed hidden event %s: %s", personal.EventFromUser, got)
for _, eventKey := range []string{
personal.EventFromUser,
personal.EventReadO2O,
personal.EventReadGroup,
personal.EventRecallO2O,
personal.EventRecallGroup,
personal.EventReactionO2O,
personal.EventReactionGroup,
} {
if !strings.Contains(got, eventKey) {
t.Fatalf("list output missing %s: %s", eventKey, got)
}
}
})
}
@@ -64,8 +76,8 @@ func TestEventListDefaultsToUser(t *testing.T) {
if !strings.Contains(got, personal.EventSingleChat) || !strings.Contains(got, "EVENT_KEY") {
t.Fatalf("list output = %s, want personal event catalog", got)
}
if strings.Contains(got, personal.EventFromUser) {
t.Fatalf("list output exposed hidden event %s: %s", personal.EventFromUser, got)
if !strings.Contains(got, personal.EventFromUser) {
t.Fatalf("list output missing public event %s: %s", personal.EventFromUser, got)
}
if strings.Contains(got, "CLIENT_ID") || strings.Contains(got, "ClientSecret") {
t.Fatalf("list default appears to use legacy application output: %s", got)
@@ -246,8 +258,8 @@ func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) {
t.Fatalf("schema output for %s leaked %q: %s", eventKey, leaked, got)
}
}
if doc["jq_root_path"] != ".data | fromjson" {
t.Fatalf("jq_root_path = %#v, want .data | fromjson", doc["jq_root_path"])
if doc["jq_root_path"] != "." {
t.Fatalf("jq_root_path = %#v, want .", doc["jq_root_path"])
}
schema, ok := doc["schema"].(map[string]any)
if !ok {
@@ -264,6 +276,80 @@ func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) {
}
}
func TestPersonalActionEventSchemaMatchesFlatOutput(t *testing.T) {
tests := []struct {
eventKeys []string
properties []string
}{
{
eventKeys: []string{personal.EventReadO2O, personal.EventReadGroup},
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "message_id",
"conversation_id", "reader", "reader_open_dingtalk_id", "sender",
"sender_open_dingtalk_id", "read_time", "event_time",
},
},
{
eventKeys: []string{personal.EventRecallO2O, personal.EventRecallGroup},
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "message_id",
"conversation_id", "recaller", "recaller_open_dingtalk_id", "sender",
"sender_open_dingtalk_id", "recall_time", "event_time",
},
},
{
eventKeys: []string{personal.EventReactionO2O, personal.EventReactionGroup},
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "message_id",
"conversation_id", "operator", "operator_open_dingtalk_id", "reaction_name",
"reaction_text", "operation_type", "operation_time", "sender",
"sender_open_dingtalk_id", "event_time",
},
},
}
for _, tt := range tests {
for _, eventKey := range tt.eventKeys {
t.Run(eventKey, func(t *testing.T) {
cmd := newEventSchemaCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{eventKey})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var doc map[string]any
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
}
if doc["event_key"] != eventKey || doc["jq_root_path"] != "." {
t.Fatalf("schema metadata = %#v", doc)
}
schema, ok := doc["schema"].(map[string]any)
if !ok {
t.Fatalf("schema = %#v", doc["schema"])
}
properties, ok := schema["properties"].(map[string]any)
if !ok || len(properties) != len(tt.properties) {
t.Fatalf("schema.properties = %#v, want exactly %d flat fields", schema["properties"], len(tt.properties))
}
for _, field := range tt.properties {
if _, ok := properties[field]; !ok {
t.Fatalf("schema missing %q: %#v", field, properties)
}
}
for _, internal := range []string{"payload", "uid", "corpid", "clientId", "filterSubId", "bizid"} {
if _, ok := properties[internal]; ok {
t.Fatalf("schema exposed internal property %q", internal)
}
}
})
}
}
}
func TestEventSchemaDefaultsToUser(t *testing.T) {
cmd := newEventSchemaCommand()
cmd.SilenceUsage = true
@@ -283,38 +369,144 @@ func TestEventSchemaDefaultsToUser(t *testing.T) {
}
}
func TestPersonalEventFromUserIsNotPubliclyAvailable(t *testing.T) {
for _, tc := range []struct {
name string
cmd *cobra.Command
args []string
}{
{
name: "schema",
cmd: newEventSchemaCommand(),
args: []string{personal.EventFromUser},
},
{
name: "consume",
cmd: newEventConsumeCommand(),
args: []string{personal.EventFromUser, "--user", "507971", "--dry-run"},
},
{
name: "status",
cmd: newEventStatusCommand(),
args: []string{"--event", personal.EventFromUser},
},
} {
t.Run(tc.name, func(t *testing.T) {
tc.cmd.SilenceUsage = true
tc.cmd.SilenceErrors = true
tc.cmd.SetArgs(tc.args)
err := tc.cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "event "+personal.EventFromUser+" is not publicly available yet") {
t.Fatalf("Execute() error = %v, want not publicly available", err)
}
})
func TestPersonalEventFromUserIsPubliclyAvailable(t *testing.T) {
if err := ensurePublicPersonalEvent(personal.EventFromUser); err != nil {
t.Fatalf("ensurePublicPersonalEvent() error = %v", err)
}
schemaCmd := newEventSchemaCommand()
schemaCmd.SilenceUsage = true
schemaCmd.SilenceErrors = true
var schemaOut bytes.Buffer
schemaCmd.SetOut(&schemaOut)
schemaCmd.SetArgs([]string{personal.EventFromUser})
if err := schemaCmd.Execute(); err != nil {
t.Fatalf("schema Execute() error = %v", err)
}
var doc map[string]any
if err := json.Unmarshal(schemaOut.Bytes(), &doc); err != nil {
t.Fatalf("schema output is not JSON: %v\n%s", err, schemaOut.String())
}
if doc["event_key"] != personal.EventFromUser || doc["rule_type"] != "sender" {
t.Fatalf("schema document = %#v", doc)
}
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
AccessToken: "access-1",
RefreshToken: "refresh-1",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "corp-1",
UserID: "user-1",
ClientID: "client-1",
})
t.Setenv("DWS_CONFIG_DIR", configDir)
consumeCmd := newEventConsumeCommand()
consumeCmd.SilenceUsage = true
consumeCmd.SilenceErrors = true
consumeCmd.SetArgs([]string{personal.EventFromUser, "--user", "test-user-001", "--dry-run"})
if err := consumeCmd.Execute(); err != nil {
t.Fatalf("consume dry-run Execute() error = %v", err)
}
openIDConsumeCmd := newEventConsumeCommand()
openIDConsumeCmd.SilenceUsage = true
openIDConsumeCmd.SilenceErrors = true
openIDConsumeCmd.SetArgs([]string{personal.EventFromUser, "--open-dingtalk-id", "open-user-1", "--dry-run"})
if err := openIDConsumeCmd.Execute(); err != nil {
t.Fatalf("consume openDingtalkId dry-run Execute() error = %v", err)
}
conflictingTargetCmd := newEventConsumeCommand()
conflictingTargetCmd.SilenceUsage = true
conflictingTargetCmd.SilenceErrors = true
conflictingTargetCmd.SetArgs([]string{personal.EventFromUser, "--user", "test-user-001", "--open-dingtalk-id", "open-user-1", "--dry-run"})
err := conflictingTargetCmd.Execute()
if err == nil || !strings.Contains(err.Error(), "--user and --open-dingtalk-id are mutually exclusive for "+personal.EventFromUser) {
t.Fatalf("conflicting target identity error = %v", err)
}
groupOpenIDCmd := newEventConsumeCommand()
groupOpenIDCmd.SilenceUsage = true
groupOpenIDCmd.SilenceErrors = true
groupOpenIDCmd.SetArgs([]string{personal.EventInChat, "--group", "cid-1", "--open-dingtalk-id", "open-user-1", "--dry-run"})
err = groupOpenIDCmd.Execute()
if err == nil || !strings.Contains(err.Error(), "--open-dingtalk-id is not supported for "+personal.EventInChat+"; use --group") {
t.Fatalf("group openDingtalkId error = %v", err)
}
missingUserCmd := newEventConsumeCommand()
missingUserCmd.SilenceUsage = true
missingUserCmd.SilenceErrors = true
missingUserCmd.SetArgs([]string{personal.EventFromUser})
err = missingUserCmd.Execute()
if err == nil || !strings.Contains(err.Error(), "one of --user or --open-dingtalk-id is required for "+personal.EventFromUser) {
t.Fatalf("missing target identity error = %v", err)
}
}
func TestEventConsumeCobraSchemaIncludesOpenDingTalkID(t *testing.T) {
root := NewRootCommand()
root.SilenceUsage = true
root.SilenceErrors = true
var out bytes.Buffer
root.SetOut(&out)
root.SetArgs([]string{"schema", "event consume"})
if err := root.Execute(); err != nil {
t.Fatalf("schema event consume Execute() error = %v", err)
}
var doc map[string]any
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
}
params, ok := doc["parameters"].(map[string]any)
if !ok {
t.Fatalf("schema parameters = %#v", doc["parameters"])
}
if _, ok := params["open-dingtalk-id"]; !ok {
t.Fatalf("schema parameters missing open-dingtalk-id: %#v", params)
}
if _, ok := params["odid"]; ok {
t.Fatalf("schema parameters unexpectedly include odid alias: %#v", params)
}
for _, name := range []string{"user", "open-dingtalk-id", "group"} {
param, ok := params[name].(map[string]any)
if !ok {
t.Fatalf("schema parameter %s = %#v", name, params[name])
}
if got, exists := param["required_when"]; exists {
t.Fatalf("schema parameter %s unexpectedly declares required_when = %#v", name, got)
}
}
constraints, ok := doc["constraints"].(map[string]any)
if !ok {
t.Fatalf("schema constraints = %#v", doc["constraints"])
}
assertJSONConstraintGroup := func(field string, want []string) {
t.Helper()
groups, ok := constraints[field].([]any)
if !ok {
t.Fatalf("schema constraint %s = %#v", field, constraints[field])
}
for _, rawGroup := range groups {
group, ok := rawGroup.([]any)
if !ok || len(group) != len(want) {
continue
}
matched := true
for i := range want {
if group[i] != want[i] {
matched = false
break
}
}
if matched {
return
}
}
t.Fatalf("schema constraint %s = %#v, missing %#v", field, groups, want)
}
assertJSONConstraintGroup("require_one_of", []string{"event_key", "subscribe-id"})
}
func TestPersonalEventSchemaRejectsTableFormat(t *testing.T) {
@@ -35,6 +35,7 @@ import (
var (
manualAgentExamplePlaceholderPattern = regexp.MustCompile(`<([^>]+)>`)
manualAgentExampleDryRunJSONPattern = regexp.MustCompile(`(?i)"dry_run"\s*:\s*true`)
manualAgentExampleDryRunPlanPattern = regexp.MustCompile(`(?i)"preview_kind"\s*:\s*"plan"`)
)
// TestManualAgentExamplesContract is the always-on gate. It validates every
@@ -391,6 +392,9 @@ func manualAgentExampleDryRunObserved(capture manualAgentExampleCapture) bool {
func manualAgentExampleDryRunEvidence(capture manualAgentExampleCapture) (string, bool) {
normalized := strings.ToLower(capture.Output)
if manualAgentExampleDryRunJSONPattern.MatchString(capture.Output) && manualAgentExampleDryRunPlanPattern.MatchString(capture.Output) {
return cli.DryRunPreviewPlan, true
}
if manualAgentExampleDryRunJSONPattern.MatchString(capture.Output) {
return cli.DryRunPreviewRequest, true
}
@@ -428,6 +432,22 @@ func TestManualAgentExampleDryRunEvidenceAcceptsSharedAndCommandPlans(t *testing
}
}
func TestCrossPlatformCoverageManualAgentExampleDryRunEvidenceClassifiesStructuredPlan(t *testing.T) {
kind, observed := manualAgentExampleDryRunEvidence(manualAgentExampleCapture{
Output: `{"dry_run":true,"executed":false,"preview_kind":"plan"}`,
DryRunChecks: 1,
})
if !observed || kind != cli.DryRunPreviewPlan {
t.Fatalf("structured plan classified as kind=%q observed=%v", kind, observed)
}
if manualAgentExampleDryRunObserved(manualAgentExampleCapture{
Output: `{"dry_run":false,"executed":false,"preview_kind":"plan"}`,
DryRunChecks: 1,
}) {
t.Fatal("non-dry-run structured plan was accepted as evidence")
}
}
func manualAgentExamplePromptObserved(output string) bool {
normalized := strings.ToLower(output)
for _, marker := range []string{
@@ -61,15 +61,24 @@ func TestEventRegistryDeliversOneTypedSchemaPath(t *testing.T) {
}
}
for flag, wantType := range map[string]string{
"dry-run": "boolean",
"duration": "string",
"event-types": "array",
"max-events": "integer",
"dry-run": "boolean",
"duration": "string",
"event-types": "array",
"max-events": "integer",
"open-dingtalk-id": "string",
} {
if got := schemaContractString(consumeParams[flag]["type"]); got != wantType {
t.Errorf("event.consume --%s type = %q, want %q", flag, got, wantType)
}
}
if _, exists := consumeParams["odid"]; exists {
t.Error("event.consume exposes unsupported --odid alias")
}
for _, name := range []string{"user", "open-dingtalk-id", "group"} {
if _, exists := consumeParams[name]["required_when"]; exists {
t.Errorf("event.consume --%s unexpectedly declares required_when", name)
}
}
if _, exists := consumeParams["duration"]["default"]; exists {
t.Error("event.consume --duration leaked zero default 0s")
}
+18 -9
View File
@@ -13,7 +13,8 @@
"effect": "write",
"effect_source": "agent-hint",
"examples": [
"dws event consume user_im_message_receive_at --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
],
"field_provenance": {
"agent_summary": {
@@ -104,7 +105,8 @@
},
"examples": {
"value": [
"dws event consume user_im_message_receive_at --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -113,7 +115,8 @@
"candidates": [
{
"value": [
"dws event consume user_im_message_receive_at --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -227,7 +230,9 @@
},
"use_when": {
"value": [
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -236,7 +241,9 @@
"candidates": [
{
"value": [
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -262,7 +269,9 @@
"skills/mono/references/products/event.md"
],
"use_when": [
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
]
},
"event list": {
@@ -754,7 +763,7 @@
},
"use_when": {
"value": [
"已知 event_key,消费前需要理解 payload 字段"
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -763,7 +772,7 @@
"candidates": [
{
"value": [
"已知 event_key,消费前需要理解 payload 字段"
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -788,7 +797,7 @@
"skills/mono/references/products/event.md"
],
"use_when": [
"已知 event_key,消费前需要理解 payload 字段"
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
]
},
"event status": {
@@ -1,6 +1,6 @@
{
"version": 1,
"source_hash": "sha256:9eac553eaaaf1e3674fe2173ab7373ee76e5ccb20494b05c0be34e0bccba9e6c",
"source_hash": "sha256:de587cba5051dd4c2715353012d8d9f2a7c5208a0c6dee4ea703cfc97b7351f0",
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
"coverage": {
"surface_products": 22,
@@ -12,7 +12,7 @@
"tools_with_avoid_when": 572,
"tools_with_examples": 572,
"tools_with_interface_mode": 572,
"unmatched_skill_tools": 124,
"unmatched_skill_tools": 120,
"unreviewed_skill_tools": 7
},
"products": {
@@ -870,20 +870,20 @@
]
},
"event": {
"agent_summary": "订阅/消费个人消息事件,并查询目录、Schema、状态或停止订阅",
"agent_summary": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
"agent_summary_source": "dws-agent-selection/event",
"avoid_when": [
"查历史聊天或主动发消息分别用 chat 查询/发送命令"
],
"field_provenance": {
"agent_summary": {
"value": "订阅/消费个人消息事件,并查询目录、Schema、状态或停止订阅",
"value": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"candidates": [
{
"value": "订阅/消费个人消息事件,并查询目录、Schema、状态或停止订阅",
"value": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
"selected": true
@@ -910,7 +910,7 @@
},
"use_when": {
"value": [
"需要实时监听个人消息事件,或管理个人事件订阅生命周期"
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -918,7 +918,7 @@
"candidates": [
{
"value": [
"需要实时监听个人消息事件,或管理个人事件订阅生命周期"
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -938,7 +938,7 @@
"skills/mono/references/products/event.md"
],
"use_when": [
"需要实时监听个人消息事件,或管理个人事件订阅生命周期"
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
]
},
"live": {
+2 -58
View File
@@ -1,6 +1,6 @@
{
"version": 1,
"source_hash": "sha256:9eac553eaaaf1e3674fe2173ab7373ee76e5ccb20494b05c0be34e0bccba9e6c",
"source_hash": "sha256:de587cba5051dd4c2715353012d8d9f2a7c5208a0c6dee4ea703cfc97b7351f0",
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
"source_files": 150,
"hint_files": 46,
@@ -36,7 +36,7 @@
"tools_with_avoid_when": 572,
"tools_with_examples": 572,
"tools_with_interface_mode": 572,
"unmatched_skill_tools": 124,
"unmatched_skill_tools": 120,
"unreviewed_skill_tools": 7
},
"source_products": [
@@ -2866,62 +2866,6 @@
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
}
},
{
"tool_path": "event schema user_im_message_receive_o2o",
"source": "skills/mono/references/products/event.md",
"line": 60,
"candidates": [
"event schema",
"event consume",
"event list"
],
"review": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
}
},
{
"tool_path": "event consume user_im_message_receive_at -f ndjson",
"source": "skills/mono/references/products/event.md",
"line": 62,
"candidates": [
"event consume",
"event list",
"event schema"
],
"review": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
}
},
{
"tool_path": "event consume user_im_message_receive_o2o",
"source": "skills/mono/references/products/event.md",
"line": 63,
"candidates": [
"event consume",
"event list",
"event schema"
],
"review": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
}
},
{
"tool_path": "event consume user_im_message_receive_group",
"source": "skills/mono/references/products/event.md",
"line": 64,
"candidates": [
"event consume",
"event list",
"event schema"
],
"review": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
}
},
{
"tool_path": "mail mailbox profile",
"source": "skills/mono/references/products/mail.md",
+119 -24
View File
@@ -1,18 +1,18 @@
{
"version": 1,
"source_hash": "sha256:afe01e0b6029a2be43669927798a02ca585f44b0e7cb1a31e8e2ee0c636e73ee",
"source_hash": "sha256:522d4b43cf13f07430a407319a772cd11e9b1f268f8d98b4d68bdb385e4e585b",
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
"catalog": {
"agent_metadata": {
"products_with_metadata": 22,
"source": "embedded-skill-metadata",
"source_hash": "sha256:9eac553eaaaf1e3674fe2173ab7373ee76e5ccb20494b05c0be34e0bccba9e6c",
"source_hash": "sha256:de587cba5051dd4c2715353012d8d9f2a7c5208a0c6dee4ea703cfc97b7351f0",
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
"surface_products": 22,
"surface_tools": 572,
"tools_with_agent_summary": 572,
"tools_with_metadata": 572,
"unmatched_skill_tools": 124,
"unmatched_skill_tools": 120,
"version": 1
},
"count": 22,
@@ -12113,7 +12113,7 @@
"skills/mono/SKILL.md",
"skills/mono/references/products/event.md"
],
"agent_summary": "订阅/消费个人消息事件,并查询目录、Schema、状态或停止订阅",
"agent_summary": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
"agent_summary_source": "dws-agent-selection/event",
"avoid_when": [
"查历史聊天或主动发消息分别用 chat 查询/发送命令"
@@ -12126,13 +12126,13 @@
"precedence": "reviewed_explicit",
"selected": true,
"source": "internal/cli/schema_hints/selection/event.json",
"value": "订阅/消费个人消息事件,并查询目录、Schema、状态或停止订阅"
"value": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"source": "internal/cli/schema_hints/selection/event.json",
"value": "订阅/消费个人消息事件,并查询目录、Schema、状态或停止订阅"
"value": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期"
},
"avoid_when": {
"candidates": [
@@ -12159,7 +12159,7 @@
"selected": true,
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"需要实时监听个人消息事件,或管理个人事件订阅生命周期"
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
]
}
],
@@ -12167,7 +12167,7 @@
"resolution": "highest_precedence",
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"需要实时监听个人消息事件,或管理个人事件订阅生命周期"
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
]
}
},
@@ -12200,7 +12200,9 @@
"risk": "medium",
"title": "订阅事件流并输出到 stdout",
"use_when": [
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
]
},
{
@@ -12254,7 +12256,7 @@
"risk": "low",
"title": "显示事件 schema",
"use_when": [
"已知 event_key,消费前需要理解 payload 字段"
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
]
},
{
@@ -12315,7 +12317,7 @@
}
],
"use_when": [
"需要实时监听个人消息事件,或管理个人事件订阅生命周期"
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
]
},
{
@@ -290152,7 +290154,8 @@
"effect": "write",
"effect_source": "agent-hint",
"examples": [
"dws event consume user_im_message_receive_at --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
],
"field_provenance": {
"agent_summary": {
@@ -290279,7 +290282,8 @@
"selected": true,
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"dws event consume user_im_message_receive_at --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
]
}
],
@@ -290288,7 +290292,8 @@
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"dws event consume user_im_message_receive_at --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
]
},
"idempotency": {
@@ -290416,7 +290421,9 @@
"selected": true,
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
]
}
],
@@ -290425,7 +290432,9 @@
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
]
}
},
@@ -290435,7 +290444,7 @@
"interface_reason": "Reviewed composite workflow: the command creates or reuses a remote personal-event subscription and coordinates the local event bus and Stream consumer; no single pinned RPC represents the workflow.",
"is_alias": false,
"name": "consume",
"parameter_count": 26,
"parameter_count": 27,
"parameters": {
"compact": {
"description": "提示 bus 客户端期望 compact 渲染(语义透传,bus 仍按原 payload 投递)",
@@ -291626,6 +291635,90 @@
"required": false,
"type": "string"
},
"open-dingtalk-id": {
"description": "单聊对端或指定发送人的 openDingtalkId(与 --user 二选一)",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "单聊对端或指定发送人的 openDingtalkId(与 --user 二选一)"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "单聊对端或指定发送人的 openDingtalkId(与 --user 二选一)"
},
"property": {
"candidates": [
{
"precedence": "inference",
"selected": true,
"source": "flag_name_inference",
"value": "openDingtalkId"
}
],
"precedence": "inference",
"resolution": "highest_precedence",
"source": "flag_name_inference",
"value": "openDingtalkId"
},
"required": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": false
}
],
"precedence": "default",
"resolution": "fallback",
"source": "default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "string"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "string"
}
},
"property": "openDingtalkId",
"required": false,
"type": "string"
},
"output-dir": {
"description": "每事件写一个文件到该目录 ({type}_{id}_{ts}.json);与 stdout 互斥",
"field_provenance": {
@@ -292587,7 +292680,7 @@
"type": "string"
},
"user": {
"description": "个人单聊对端 userId",
"description": "单聊对端或指定发送人的 userId(与 --open-dingtalk-id 二选一)",
"field_provenance": {
"description": {
"candidates": [
@@ -292595,7 +292688,7 @@
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "个人单聊对端 userId"
"value": "单聊对端或指定发送人的 userId(与 --open-dingtalk-id 二选一)"
},
{
"precedence": "default",
@@ -292607,7 +292700,7 @@
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "个人单聊对端 userId"
"value": "单聊对端或指定发送人的 userId(与 --open-dingtalk-id 二选一)"
},
"property": {
"candidates": [
@@ -292688,7 +292781,9 @@
"source": "reviewed_command_registry",
"title": "订阅事件流并输出到 stdout",
"use_when": [
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
]
},
"event.list": {
@@ -293649,7 +293744,7 @@
"selected": true,
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"已知 event_key,消费前需要理解 payload 字段"
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
]
}
],
@@ -293658,7 +293753,7 @@
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"已知 event_key,消费前需要理解 payload 字段"
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
]
}
},
@@ -293779,7 +293874,7 @@
"source": "reviewed_command_registry",
"title": "显示事件 schema",
"use_when": [
"已知 event_key,消费前需要理解 payload 字段"
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
]
},
"event.status": {
+86 -18
View File
@@ -630,36 +630,104 @@
"reason": "已审查的旧路径或跨产品文档前缀,固定映射到当前公开命令"
},
"event consume user_im_message_receive_at -f ndjson": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 和输出参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_receive_group": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_receive_o2o": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_receive_user": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_read_o2o": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_read_group": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_recall_o2o": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_recall_group": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_reaction_o2o": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_reaction_group": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event schema user_im_message_receive_at": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_receive_group": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_receive_o2o": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event status": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
"event schema user_im_message_receive_user": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event stop": {
"status": "out_of_surface",
"reason": "该产品不在当前 21 产品公开命令面中"
"event schema user_im_message_read_o2o": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_read_group": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_recall_o2o": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_recall_group": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_reaction_o2o": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_reaction_group": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"mail aisearch person": {
"status": "alias",
@@ -595,6 +595,41 @@
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_receive_user": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_read_o2o": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_read_group": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_recall_o2o": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_recall_group": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_reaction_o2o": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_reaction_group": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event schema user_im_message_receive_at": {
"status": "alias",
"target": "event schema",
@@ -610,6 +645,41 @@
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_receive_user": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_read_o2o": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_read_group": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_recall_o2o": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_recall_group": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_reaction_o2o": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_reaction_group": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"mail aisearch person": {
"status": "alias",
"target": "aisearch person",
@@ -11,14 +11,17 @@
"event.consume": {
"agent_summary": "订阅并持续消费指定个人事件,输出 NDJSON 事件流",
"use_when": [
"需要实时监听 @我、指定单聊或指定群的后续消息事件"
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
],
"avoid_when": [
"只查历史聊天记录时用 chat 查询命令",
"只看事件目录/字段时用 event list / event schema"
],
"examples": [
"dws event consume user_im_message_receive_at --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
],
"reviewed": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
@@ -56,7 +59,7 @@
"event.schema": {
"agent_summary": "查询指定个人事件码的 payload 字段结构",
"use_when": [
"已知 event_key,消费前需要理解 payload 字段"
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
],
"avoid_when": [
"查询 CLI 命令参数契约时用顶层 dws schema",
@@ -122,9 +125,9 @@
},
"products": {
"event": {
"agent_summary": "订阅/消费个人消息事件,并查询目录、Schema、状态或停止订阅",
"agent_summary": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
"use_when": [
"需要实时监听个人消息事件,或管理个人事件订阅生命周期"
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
],
"avoid_when": [
"查历史聊天或主动发消息分别用 chat 查询/发送命令"
+4 -2
View File
@@ -377,9 +377,11 @@ func TestCrossPlatformCoverageDaemonMethodEdges(t *testing.T) {
d.conns.Store("not-a-conn", struct{}{})
d.conns.Store(&queryConn{}, struct{}{})
d.consumerWG.Add(1)
d.shutdown()
acceptDone := make(chan struct{})
close(acceptDone)
d.shutdown(acceptDone)
d.consumerWG.Done()
d.shutdown()
d.shutdown(acceptDone)
pr, pw, err := os.Pipe()
if err != nil {
+13 -7
View File
@@ -246,10 +246,11 @@ func Run(ctx context.Context, cfg Config) error {
}
// 7. Graceful shutdown — cancel runCtx first so all background
// goroutines wake up, then close listener / drain consumers.
// goroutines wake up, then stop accepting connections before draining
// consumers. The accept-loop barrier is required before WaitGroup.Wait:
// sync.WaitGroup forbids a positive Add racing with Wait.
cancelRun()
d.shutdown()
<-acceptDone
d.shutdown(acceptDone)
<-idleDone
<-dropWarnDone
@@ -291,6 +292,10 @@ func (d *daemon) acceptLoop(ctx context.Context) {
d.log.Warn("bus: accept error", "err", err)
continue
}
// Track the connection before publishing the handler goroutine. Once
// acceptLoop returns, shutdown can therefore close every accepted
// connection before waiting for handlers to drain.
d.conns.Store(conn, struct{}{})
d.consumerWG.Add(1)
go func() {
defer d.consumerWG.Done()
@@ -303,7 +308,6 @@ func (d *daemon) acceptLoop(ctx context.Context) {
// Hello → register with Hub → spawn writer goroutine → read until EOF/Bye.
// Always Unregisters and Closes on exit (plan invariant #5).
func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
d.conns.Store(conn, struct{}{})
defer func() {
d.conns.Delete(conn)
conn.Close()
@@ -478,9 +482,10 @@ func (d *daemon) triggerShutdown(reason string) {
// 1. mark shuttingDown so acceptLoop exits cleanly
// 2. broadcast Bye to all consumers
// 3. close listener (interrupts pending Accept)
// 4. wait for all per-connection goroutines to drain
// 5. lock + meta cleanup via Run's defers
func (d *daemon) shutdown() {
// 4. wait for acceptLoop to return so no future consumerWG.Add can occur
// 5. close all accepted connections and wait for handlers to drain
// 6. lock + meta cleanup via Run's defers
func (d *daemon) shutdown(acceptDone <-chan struct{}) {
d.shutdownMu.Lock()
defer d.shutdownMu.Unlock()
if !d.shuttingDown.CompareAndSwap(false, true) {
@@ -488,6 +493,7 @@ func (d *daemon) shutdown() {
}
d.hub.Broadcast(transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"})
_ = d.listener.Close()
<-acceptDone
// Force-close all open IPC connections so any reader goroutine blocked
// on Read() returns with a network error and exits cleanly. Without
// this the consumerWG never drains and Run hangs forever.
+101 -29
View File
@@ -16,6 +16,8 @@ package consume
import (
"encoding/json"
"fmt"
"io"
"sync"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/registry"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
@@ -49,8 +51,8 @@ const (
// event, newline-terminated). Useful when piping into jq / a tool
// that wants the cloud payload verbatim without our envelope.
FormatRaw Format = "raw"
// FormatCompact runs the per-event-type compact processor (see
// registry.LookupProcessor) and emits one flattened JSON line.
// FormatCompact emits one compact JSON line. Personal streams use their
// configured business projection; other streams use the registry processor.
FormatCompact Format = "compact"
)
@@ -90,27 +92,103 @@ type Formatter interface {
Render(ev transport.Event) ([]byte, error)
}
// NewFormatter returns a Formatter for the given Format. Compact wraps
// registry.LookupProcessor so adding a new specialised compactor is just
// a registry-side change. Returns an error only if format is an internally
// unsupported value (defensive — NormalizeFormat guarantees the input is
// one of the constants).
func NewFormatter(format Format) (Formatter, error) {
// Projector maps a transport envelope to the public value rendered by
// structured formats. Returning a value together with an error means the
// value is a safe fallback and should still be emitted after a warning.
type Projector func(ev transport.Event) (any, error)
type formatterConfig struct {
projector Projector
warnings io.Writer
warnOnce sync.Once
}
// FormatterOption configures structured event rendering. Raw output always
// bypasses these options and preserves the source Data string.
type FormatterOption func(*formatterConfig)
func WithProjector(projector Projector) FormatterOption {
return func(cfg *formatterConfig) { cfg.projector = projector }
}
func WithProjectionWarnings(w io.Writer) FormatterOption {
return func(cfg *formatterConfig) {
if w != nil {
cfg.warnings = w
}
}
}
// NewFormatter returns a Formatter for the given Format. When no projector
// is configured, compact dispatches through registry.LookupProcessor. Returns
// an error only if format is internally unsupported.
func NewFormatter(format Format, opts ...FormatterOption) (Formatter, error) {
cfg := &formatterConfig{warnings: io.Discard}
for _, opt := range opts {
if opt != nil {
opt(cfg)
}
}
switch format {
case FormatNDJSON:
return &ndjsonFormatter{}, nil
return &structuredFormatter{config: cfg}, nil
case FormatJSON, FormatPretty:
return &prettyFormatter{}, nil
return &structuredFormatter{config: cfg, pretty: true}, nil
case FormatRaw:
return &rawFormatter{}, nil
case FormatCompact:
return &compactFormatter{}, nil
return &structuredFormatter{config: cfg, compact: true}, nil
default:
return nil, fmt.Errorf("consume: unsupported format %q", format)
}
}
// ndjsonFormatter encodes each Event as one compact JSON line + '\n'.
// structuredFormatter renders either the transport envelope or a projected
// business value. The same projector is used by ndjson/json/pretty/compact.
type structuredFormatter struct {
config *formatterConfig
pretty bool
compact bool
}
func (f *structuredFormatter) Render(ev transport.Event) ([]byte, error) {
value := any(ev)
if f.config.projector != nil {
projected, err := f.config.projector(ev)
if projected != nil {
value = projected
}
if err != nil {
f.config.warnOnce.Do(func() {
fmt.Fprintf(f.config.warnings,
"WARN: personal event output projection failed; using raw envelope event_id=%q event_type=%q: %v\n",
ev.EventID, ev.EventType, err)
})
}
} else if f.compact {
value = registry.LookupProcessor(ev.EventType)(ev)
}
var (
b []byte
err error
)
switch {
case f.pretty:
b, err = marshalEventIndent(value, "", " ")
case f.compact:
b, err = marshalCompact(value)
default:
b, err = marshalEvent(value)
}
if err != nil {
return nil, err
}
return append(b, '\n'), nil
}
// The basic formatters remain available for focused encoder tests. The public
// construction path uses structuredFormatter so personal event projections are
// applied consistently across all structured formats.
type ndjsonFormatter struct{}
func (ndjsonFormatter) Render(ev transport.Event) ([]byte, error) {
@@ -121,9 +199,6 @@ func (ndjsonFormatter) Render(ev transport.Event) ([]byte, error) {
return append(b, '\n'), nil
}
// prettyFormatter encodes each Event as multi-line indented JSON + '\n'.
// json.MarshalIndent does not append a trailing newline; we add one so
// successive events are visually separated in the output.
type prettyFormatter struct{}
func (prettyFormatter) Render(ev transport.Event) ([]byte, error) {
@@ -134,6 +209,17 @@ func (prettyFormatter) Render(ev transport.Event) ([]byte, error) {
return append(b, '\n'), nil
}
type compactFormatter struct{}
func (compactFormatter) Render(ev transport.Event) ([]byte, error) {
value := registry.LookupProcessor(ev.EventType)(ev)
b, err := marshalCompact(value)
if err != nil {
return nil, err
}
return append(b, '\n'), nil
}
// rawFormatter writes ev.Data verbatim. If Data is already JSON it stays
// JSON; if it's some other string format it stays that. A trailing newline
// is appended so successive raw events are separable.
@@ -147,17 +233,3 @@ func (rawFormatter) Render(ev transport.Event) ([]byte, error) {
}
return out, nil
}
// compactFormatter dispatches to the registry per event_type and writes
// the flattened map as one compact JSON line + '\n'.
type compactFormatter struct{}
func (compactFormatter) Render(ev transport.Event) ([]byte, error) {
p := registry.LookupProcessor(ev.EventType)
v := p(ev)
b, err := marshalCompact(v)
if err != nil {
return nil, err
}
return append(b, '\n'), nil
}
+80
View File
@@ -14,7 +14,9 @@
package consume
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
@@ -149,6 +151,84 @@ func TestCompactFormatter_DispatchesPerEventType(t *testing.T) {
}
}
func TestCrossPlatformCoverageStructuredFormatsUseProjector(t *testing.T) {
projector := Projector(func(ev transport.Event) (any, error) {
return map[string]any{"type": ev.EventType, "content": "hello"}, nil
})
for _, format := range []Format{FormatNDJSON, FormatJSON, FormatPretty, FormatCompact} {
t.Run(string(format), func(t *testing.T) {
f, err := NewFormatter(format, WithProjector(projector))
if err != nil {
t.Fatal(err)
}
out, err := f.Render(transport.Event{EventType: "personal.test", Data: `{"raw":true}`})
if err != nil {
t.Fatal(err)
}
var got map[string]any
if err := json.Unmarshal(bytes.TrimSpace(out), &got); err != nil {
t.Fatalf("output is not JSON: %v", err)
}
if got["type"] != "personal.test" || got["content"] != "hello" {
t.Fatalf("projected output = %#v", got)
}
if _, ok := got["data"]; ok {
t.Fatalf("projected output leaked transport envelope: %#v", got)
}
})
}
}
func TestCrossPlatformCoverageProjectionFailureWarnsAndEmitsFallback(t *testing.T) {
var warnings bytes.Buffer
f, err := NewFormatter(
FormatNDJSON,
WithProjector(func(ev transport.Event) (any, error) { return ev, errors.New("bad payload") }),
WithProjectionWarnings(&warnings),
)
if err != nil {
t.Fatal(err)
}
ev := transport.Event{EventID: "outer", EventType: "user_im_message_receive_o2o", Data: "not-json"}
for i := 0; i < 2; i++ {
out, err := f.Render(ev)
if err != nil {
t.Fatal(err)
}
var got transport.Event
if err := json.Unmarshal(bytes.TrimSpace(out), &got); err != nil || got.EventID != "outer" {
t.Fatalf("fallback output = %s, err = %v", out, err)
}
}
if count := strings.Count(warnings.String(), "WARN: personal event output projection failed"); count != 1 {
t.Fatalf("warning count = %d, output = %q", count, warnings.String())
}
for _, want := range []string{`event_id="outer"`, `event_type="user_im_message_receive_o2o"`, "bad payload"} {
if !strings.Contains(warnings.String(), want) {
t.Fatalf("warning missing %q: %q", want, warnings.String())
}
}
}
func TestCrossPlatformCoverageRawFormatBypassesProjector(t *testing.T) {
called := false
raw := `{"payload":{"uid":100001,"clientId":"internal-client","filterSubId":"internal-filter","bizid":"internal-bizid"}}`
f, err := NewFormatter(FormatRaw, WithProjector(func(transport.Event) (any, error) {
called = true
return map[string]any{"projected": true}, nil
}))
if err != nil {
t.Fatal(err)
}
out, err := f.Render(transport.Event{Data: raw})
if err != nil {
t.Fatal(err)
}
if called || string(out) != raw+"\n" {
t.Fatalf("raw output = %q, projector called = %v", out, called)
}
}
func TestNewFormatter_RejectsUnknown(t *testing.T) {
if _, err := NewFormatter(Format("nope")); err == nil {
t.Fatal("expected error for unknown format")
+2 -2
View File
@@ -65,8 +65,8 @@ func (p *Pipeline) Close() error { return p.sink.Close() }
// stdoutW is injected for tests (os.Stdout in production). When nil it
// defaults to io.Discard so a misconfigured pipeline never writes to
// the host process's actual stdout.
func BuildPipeline(format Format, outputDir string, routes []Route, stdoutW io.Writer) (*Pipeline, error) {
fmter, err := NewFormatter(format)
func BuildPipeline(format Format, outputDir string, routes []Route, stdoutW io.Writer, formatterOpts ...FormatterOption) (*Pipeline, error) {
fmter, err := NewFormatter(format, formatterOpts...)
if err != nil {
return nil, err
}
+42
View File
@@ -62,6 +62,48 @@ func TestPipeline_OutputDirFallback(t *testing.T) {
}
}
func TestCrossPlatformCoveragePipelineStdoutAndOutputDirUseSameProjection(t *testing.T) {
ev := transport.Event{
EventType: "personal.message",
EventID: "ev1",
ReceivedAtUnixMS: 100,
}
projector := WithProjector(func(transport.Event) (any, error) {
return map[string]any{"type": "personal.message", "content": "hello"}, nil
})
var stdout bytes.Buffer
stdoutPipeline, err := BuildPipeline(FormatNDJSON, "", nil, &stdout, projector)
if err != nil {
t.Fatal(err)
}
defer stdoutPipeline.Close()
if err := stdoutPipeline.Deliver(ev); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
filePipeline, err := BuildPipeline(FormatNDJSON, dir, nil, nil, projector)
if err != nil {
t.Fatal(err)
}
defer filePipeline.Close()
if err := filePipeline.Deliver(ev); err != nil {
t.Fatal(err)
}
entries, err := os.ReadDir(dir)
if err != nil || len(entries) != 1 {
t.Fatalf("ReadDir() entries = %d, err = %v", len(entries), err)
}
fileBody, err := os.ReadFile(filepath.Join(dir, entries[0].Name()))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(stdout.Bytes(), fileBody) {
t.Fatalf("stdout = %q, file = %q", stdout.Bytes(), fileBody)
}
}
func TestPipeline_RouteWithStdoutFallback(t *testing.T) {
root := t.TempDir()
imDir := filepath.Join(root, "im")
+21 -2
View File
@@ -95,6 +95,14 @@ type Config struct {
OutputDir string
// Routes are pre-parsed --route specs. Empty = no routing.
Routes []Route
// Projector, when set, maps transport envelopes to the public value used
// by all structured formats. Raw format always bypasses it.
Projector Projector
// ReadySubscribeID identifies the personal subscription in the stable
// ready marker emitted after HelloAck. It is separate from SubscribeID
// because debug raw mode deliberately clears the latter's local filter.
ReadySubscribeID string
// Stdin, when non-nil, is watched for EOF: closing stdin triggers a
// graceful shutdown (reason: signal). This wires the AI-subprocess
@@ -175,7 +183,14 @@ func Run(ctx context.Context, cfg Config) error {
defer cancelRun()
ctx = runCtx
pipeline, err := BuildPipeline(cfg.Format, cfg.OutputDir, cfg.Routes, cfg.Stdout)
pipeline, err := BuildPipeline(
cfg.Format,
cfg.OutputDir,
cfg.Routes,
cfg.Stdout,
WithProjector(cfg.Projector),
WithProjectionWarnings(cfg.Stderr),
)
if err != nil {
return fmt.Errorf("consume: build pipeline: %w", err)
}
@@ -221,7 +236,11 @@ func Run(ctx context.Context, cfg Config) error {
// Contract: a fixed ready line on stderr BEFORE any stdout event.
// Parents block on stderr until this appears, then read stdout.
if cfg.EventKey != "" {
fmt.Fprintf(cfg.Stderr, "[event] ready event_key=%s bus_pid=%d\n", cfg.EventKey, ack.BusPID)
fmt.Fprintf(cfg.Stderr, "[event] ready event_key=%s bus_pid=%d", cfg.EventKey, ack.BusPID)
if cfg.ReadySubscribeID != "" {
fmt.Fprintf(cfg.Stderr, " subscribe_id=%s", cfg.ReadySubscribeID)
}
fmt.Fprintln(cfg.Stderr)
} else {
fmt.Fprintf(cfg.Stderr, "[event] ready bus_pid=%d\n", ack.BusPID)
}
+95 -14
View File
@@ -14,6 +14,7 @@
package consume
import (
"bufio"
"bytes"
"context"
"encoding/json"
@@ -139,13 +140,15 @@ func TestRun_StdoutNDJSON(t *testing.T) {
ctx, consumeCancel := context.WithTimeout(context.Background(), 3*time.Second)
defer consumeCancel()
err := Run(ctx, Config{
WorkDir: dir,
IPCEndpoint: sock,
ClientID: "ding_test",
Stdout: &stdout,
Stderr: &stderr,
EventTypes: []string{"im.*"},
MaxEvents: 2,
WorkDir: dir,
IPCEndpoint: sock,
ClientID: "ding_test",
Stdout: &stdout,
Stderr: &stderr,
EventTypes: []string{"im.*"},
EventKey: "im.message.receive_v1",
ReadySubscribeID: "sub-1",
MaxEvents: 2,
})
if err != nil {
t.Fatalf("Run: %v", err)
@@ -170,6 +173,10 @@ func TestRun_StdoutNDJSON(t *testing.T) {
if !strings.Contains(stderr.String(), "[event] ready ") {
t.Errorf("stderr missing ready marker:\n%s", stderr.String())
}
if !strings.Contains(stderr.String(), "[event] ready event_key=im.message.receive_v1 bus_pid=") ||
!strings.Contains(stderr.String(), " subscribe_id=sub-1") {
t.Errorf("stderr missing ready marker:\n%s", stderr.String())
}
}
func TestRun_QuietSuppressesStderr(t *testing.T) {
@@ -184,13 +191,15 @@ func TestRun_QuietSuppressesStderr(t *testing.T) {
ctx, consumeCancel := context.WithTimeout(context.Background(), 3*time.Second)
defer consumeCancel()
err := Run(ctx, Config{
WorkDir: dir,
IPCEndpoint: sock,
ClientID: "ding_test",
Stdout: &stdout,
Stderr: &stderr,
Quiet: true,
MaxEvents: 1,
WorkDir: dir,
IPCEndpoint: sock,
ClientID: "ding_test",
Stdout: &stdout,
Stderr: &stderr,
Quiet: true,
EventKey: "im.message.receive_v1",
ReadySubscribeID: "sub-1",
MaxEvents: 1,
})
if err != nil {
t.Fatalf("Run: %v", err)
@@ -203,6 +212,78 @@ func TestRun_QuietSuppressesStderr(t *testing.T) {
}
}
func TestRun_WritesFirstProjectedEventBeforeConsumeExits(t *testing.T) {
skipOnWindows(t)
dir, sock, cancelBus, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{
{EventID: "1", EventType: "personal.message", Data: `{"content":"hello"}`},
})
defer func() { cancelBus(); <-runDone }()
reader, writer, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer reader.Close()
defer writer.Close()
ctx, cancelConsume := context.WithCancel(context.Background())
consumeDone := make(chan error, 1)
go func() {
consumeDone <- Run(ctx, Config{
WorkDir: dir,
IPCEndpoint: sock,
ClientID: "ding_test",
Stdout: writer,
Stderr: io.Discard,
Projector: func(ev transport.Event) (any, error) {
return map[string]any{"type": ev.EventType, "content": "hello"}, nil
},
})
}()
go func() {
time.Sleep(150 * time.Millisecond)
close(trigger)
}()
lineCh := make(chan string, 1)
errCh := make(chan error, 1)
go func() {
line, err := bufio.NewReader(reader).ReadString('\n')
if err != nil {
errCh <- err
return
}
lineCh <- line
}()
select {
case line := <-lineCh:
var got map[string]any
if err := json.Unmarshal([]byte(line), &got); err != nil || got["content"] != "hello" {
t.Fatalf("first line = %q, err = %v", line, err)
}
case err := <-errCh:
t.Fatalf("read first line: %v", err)
case <-time.After(2 * time.Second):
t.Fatal("first event was not readable while consume was running")
}
select {
case err := <-consumeDone:
t.Fatalf("consume exited before cancellation: %v", err)
default:
}
cancelConsume()
select {
case err := <-consumeDone:
if err != nil {
t.Fatalf("Run() after cancellation = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("consume did not exit after cancellation")
}
}
func TestRun_CtxCancelReturnsCleanly(t *testing.T) {
skipOnWindows(t)
dir, sock, cancel, runDone, _ := bringUpBus(t, nil)
+71 -4
View File
@@ -21,6 +21,7 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"strings"
"testing"
@@ -71,7 +72,7 @@ func TestClientCreateSubscriptionDWSRequestAndArrayResponse(t *testing.T) {
RuleType: "singleChat",
Name: "test-o2o",
RuleParam: map[string]any{
"targetUid": "507971",
"targetUid": "test-user-001",
"targetUidType": "staffId",
},
Filter: map[string]any{"field": "payload.body.content", "op": "contains", "value": "P0"},
@@ -93,7 +94,7 @@ func TestClientCreateSubscriptionDWSRequestAndArrayResponse(t *testing.T) {
if err := json.Unmarshal([]byte(gotReq.FilterRule), &filterRule); err != nil {
t.Fatalf("filterRule is not JSON: %q: %v", gotReq.FilterRule, err)
}
if filterRule["targetUid"] != "507971" || filterRule["targetUidType"] != "staffId" {
if filterRule["targetUid"] != "test-user-001" || filterRule["targetUidType"] != "staffId" {
t.Fatalf("filterRule = %#v", filterRule)
}
if gotReq.Ext["ruleType"] != "singleChat" || gotReq.Ext["name"] != "test-o2o" || gotReq.Ext["idempotencyKey"] != "idem-1" {
@@ -107,6 +108,72 @@ func TestClientCreateSubscriptionDWSRequestAndArrayResponse(t *testing.T) {
}
}
func TestClientCreateRuleBasedSubscriptionsUsesDocumentedRuleParam(t *testing.T) {
tests := []struct {
name string
eventKey string
opts RuleOptions
wantRule map[string]any
}{
{"receive_o2o/staffId", EventSingleChat, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}},
{"receive_o2o/openDingtalkId", EventSingleChat, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
{"read_o2o/staffId", EventReadO2O, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}},
{"read_o2o/openDingtalkId", EventReadO2O, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
{"recall_o2o/staffId", EventRecallO2O, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}},
{"recall_o2o/openDingtalkId", EventRecallO2O, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
{"reaction_o2o/staffId", EventReactionO2O, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}},
{"reaction_o2o/openDingtalkId", EventReactionO2O, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
{"receive_user/staffId", EventFromUser, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}},
{"receive_user/openDingtalkId", EventFromUser, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
{"read_group", EventReadGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"recall_group", EventRecallGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"reaction_group", EventReactionGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotPath string
var gotReq dwsCreateSubscriptionRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Errorf("decode request: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "result": []string{"sub-rule"}})
}))
defer srv.Close()
ruleType, ruleParam, err := BuildRuleParam(tt.eventKey, tt.opts)
if err != nil {
t.Fatal(err)
}
client := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
if _, err := client.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: tt.eventKey,
RuleType: ruleType,
RuleParam: ruleParam,
}); err != nil {
t.Fatal(err)
}
if gotPath != "/subscription/user" || gotReq.EventKey != tt.eventKey {
t.Fatalf("path = %q, eventKey = %q", gotPath, gotReq.EventKey)
}
var gotRule map[string]any
if err := json.Unmarshal([]byte(gotReq.FilterRule), &gotRule); err != nil {
t.Fatalf("filterRule = %q: %v", gotReq.FilterRule, err)
}
if !reflect.DeepEqual(gotRule, tt.wantRule) {
t.Fatalf("filterRule = %#v, want %#v", gotRule, tt.wantRule)
}
if gotReq.Ext["ruleType"] != ruleType {
t.Fatalf("ext.ruleType = %#v, want %q", gotReq.Ext["ruleType"], ruleType)
}
})
}
}
func TestClientCreateSubscriptionObjectResponses(t *testing.T) {
cases := []map[string]any{
{"subId": "sub-camel", "eventKey": EventMention, "sourceId": "open", "status": 1},
@@ -151,7 +218,7 @@ func TestClientDebugLogCreateSubscriptionRequestResponse(t *testing.T) {
EventKey: EventSingleChat,
RuleType: "singleChat",
RuleParam: map[string]any{
"targetUid": "507971",
"targetUid": "test-user-001",
"targetUidType": "staffId",
},
IdempotencyKey: "idem-1",
@@ -167,7 +234,7 @@ func TestClientDebugLogCreateSubscriptionRequestResponse(t *testing.T) {
EventSingleChat,
"filterRule",
"targetUid",
"507971",
"test-user-001",
"sub-1",
"req-ok",
} {
+449
View File
@@ -0,0 +1,449 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package personal
import (
"bytes"
"encoding/json"
"fmt"
"reflect"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
// MessageEventOutput is the stable business-facing output for personal
// message receive events. Schema output is generated from these tags so the
// documented fields cannot drift from the values written by consume.
type MessageEventOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
MessageID string `json:"message_id" description:"开放消息 ID" format:"open_message_id"`
ConversationID string `json:"conversation_id" description:"会话 ID" format:"open_conversation_id"`
Sender string `json:"sender" description:"发送人展示名"`
SenderOpenDingTalkID string `json:"sender_open_dingtalk_id" description:"发送人开放 ID" format:"open_dingtalk_id"`
Content string `json:"content" description:"消息正文"`
CreateTime string `json:"create_time" description:"消息创建时间"`
EventTime int64 `json:"event_time" description:"消息事件时间戳" format:"timestamp_ms"`
}
type ReadEventOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
MessageID string `json:"message_id" description:"被读取消息的开放消息 ID" format:"open_message_id"`
ConversationID string `json:"conversation_id" description:"会话 ID" format:"open_conversation_id"`
Reader string `json:"reader" description:"读取消息的用户展示名"`
ReaderOpenDingTalkID string `json:"reader_open_dingtalk_id" description:"读取消息的用户开放 ID" format:"open_dingtalk_id"`
Sender string `json:"sender" description:"原消息发送人展示名"`
SenderOpenDingTalkID string `json:"sender_open_dingtalk_id" description:"原消息发送人开放 ID" format:"open_dingtalk_id"`
ReadTime string `json:"read_time" description:"消息读取时间"`
EventTime int64 `json:"event_time" description:"消息事件时间戳" format:"timestamp_ms"`
}
type RecallEventOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
MessageID string `json:"message_id" description:"被撤回消息的开放消息 ID" format:"open_message_id"`
ConversationID string `json:"conversation_id" description:"会话 ID" format:"open_conversation_id"`
Recaller string `json:"recaller" description:"撤回消息的用户展示名"`
RecallerOpenDingTalkID string `json:"recaller_open_dingtalk_id" description:"撤回消息的用户开放 ID" format:"open_dingtalk_id"`
Sender string `json:"sender" description:"原消息发送人展示名"`
SenderOpenDingTalkID string `json:"sender_open_dingtalk_id" description:"原消息发送人开放 ID" format:"open_dingtalk_id"`
RecallTime string `json:"recall_time" description:"消息撤回时间"`
EventTime int64 `json:"event_time" description:"消息事件时间戳" format:"timestamp_ms"`
}
type ReactionEventOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
MessageID string `json:"message_id" description:"收到表情回应的开放消息 ID" format:"open_message_id"`
ConversationID string `json:"conversation_id" description:"会话 ID" format:"open_conversation_id"`
Operator string `json:"operator" description:"执行表情回应操作的用户展示名"`
OperatorOpenDingTalkID string `json:"operator_open_dingtalk_id" description:"执行表情回应操作的用户开放 ID" format:"open_dingtalk_id"`
ReactionName string `json:"reaction_name" description:"表情回应名称"`
ReactionText string `json:"reaction_text" description:"表情回应文本"`
OperationType string `json:"operation_type" description:"表情回应操作类型"`
OperationTime string `json:"operation_time" description:"表情回应操作时间"`
Sender string `json:"sender" description:"原消息发送人展示名"`
SenderOpenDingTalkID string `json:"sender_open_dingtalk_id" description:"原消息发送人开放 ID" format:"open_dingtalk_id"`
EventTime int64 `json:"event_time" description:"消息事件时间戳" format:"timestamp_ms"`
}
type baseEventOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
}
type personalEventData struct {
EventID string `json:"eventId"`
EventKey string `json:"eventKey"`
OccurredAtMS int64 `json:"occurredAtMs"`
SubID string `json:"subId"`
Payload json.RawMessage `json:"payload"`
}
type personalMessagePayload struct {
EventTime int64 `json:"event_time"`
Body struct {
CreateTime string `json:"createTime"`
Sender string `json:"sender"`
OpenMessageID string `json:"openMessageId"`
SenderOpenDingTalkID string `json:"senderOpenDingTalkId"`
OpenConversationID string `json:"openConversationId"`
Content string `json:"content"`
} `json:"body"`
}
type personalReadPayload struct {
EventTime int64 `json:"event_time"`
Body struct {
MessageID string `json:"openMessageId"`
ConversationID string `json:"openConversationId"`
Reader string `json:"reader"`
ReaderOpenDingTalkID string `json:"readerOpenDingTalkId"`
Sender string `json:"sender"`
SenderOpenDingTalkID string `json:"senderOpenDingTalkId"`
ReadTime string `json:"msgReadTime"`
} `json:"body"`
}
type personalRecallPayload struct {
EventTime int64 `json:"event_time"`
Body struct {
MessageID string `json:"openMessageId"`
ConversationID string `json:"openConversationId"`
Recaller string `json:"recaller"`
RecallerOpenDingTalkID string `json:"recallerOpenDingTalkId"`
Sender string `json:"sender"`
SenderOpenDingTalkID string `json:"senderOpenDingTalkId"`
RecallTime string `json:"msgRecallTime"`
} `json:"body"`
}
type personalReactionPayload struct {
EventTime int64 `json:"event_time"`
Body personalReactionBody `json:"body"`
}
type personalReactionBody struct {
MessageID string `json:"openSourceMessageId"`
ConversationID string `json:"openConversationId"`
Operator string `json:"oper"`
OperatorOpenDingTalkID string `json:"-"`
ReactionName string `json:"emotionName"`
ReactionText string `json:"emotionText"`
OperationType string `json:"operateType"`
OperationTime string `json:"operateTime"`
Sender string `json:"sender"`
SenderOpenDingTalkID string `json:"senderOpenDingTalkId"`
}
func (b *personalReactionBody) UnmarshalJSON(data []byte) error {
// encoding/json otherwise falls back to case-insensitive field matching.
// Read this protocol field from a map so only operOpenDingtalkId is accepted.
type bodyAlias personalReactionBody
var decoded bodyAlias
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return err
}
if raw, ok := fields["operOpenDingtalkId"]; ok {
if err := json.Unmarshal(raw, &decoded.OperatorOpenDingTalkID); err != nil {
return fmt.Errorf("decode operOpenDingtalkId: %w", err)
}
}
*b = personalReactionBody(decoded)
return nil
}
// ProjectOutput converts the transport envelope into the stable personal
// event output. On malformed Data it returns the original envelope together
// with an error; the formatter logs the warning and still emits that envelope.
func ProjectOutput(ev transport.Event) (any, error) {
data, err := decodePersonalEventData(ev.Data)
if err != nil {
return ev, fmt.Errorf("decode personal event data: %w", err)
}
eventType := firstNonEmptyOutput(ev.EventType, data.EventKey)
eventID := firstNonEmptyOutput(data.EventID, ev.EventID)
timestamp := data.OccurredAtMS
if timestamp == 0 {
timestamp = ev.EventBornTime
}
subscribeID := firstNonEmptyOutput(ev.SubscribeID, data.SubID)
if isMessageReceiveEvent(eventType) {
var payload personalMessagePayload
if err := decodeRequiredPayload(data.Payload, &payload); err != nil {
return ev, fmt.Errorf("decode personal message payload: %w", err)
}
return MessageEventOutput{
Type: eventType,
EventID: eventID,
Timestamp: timestamp,
SubscribeID: subscribeID,
MessageID: payload.Body.OpenMessageID,
ConversationID: payload.Body.OpenConversationID,
Sender: payload.Body.Sender,
SenderOpenDingTalkID: payload.Body.SenderOpenDingTalkID,
Content: payload.Body.Content,
CreateTime: payload.Body.CreateTime,
EventTime: payload.EventTime,
}, nil
}
base := baseEventOutput{
Type: eventType,
EventID: eventID,
Timestamp: timestamp,
SubscribeID: subscribeID,
}
switch {
case isReadEvent(eventType):
return projectReadEvent(ev, base, data.Payload)
case isRecallEvent(eventType):
return projectRecallEvent(ev, base, data.Payload)
case isReactionEvent(eventType):
return projectReactionEvent(ev, base, data.Payload)
default:
return ev, fmt.Errorf("unsupported personal event type %q", eventType)
}
}
func projectReadEvent(ev transport.Event, base baseEventOutput, raw json.RawMessage) (any, error) {
var payload personalReadPayload
if err := decodeRequiredPayload(raw, &payload); err != nil {
return ev, fmt.Errorf("decode personal read payload: %w", err)
}
return ReadEventOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
MessageID: payload.Body.MessageID,
ConversationID: payload.Body.ConversationID,
Reader: payload.Body.Reader,
ReaderOpenDingTalkID: payload.Body.ReaderOpenDingTalkID,
Sender: payload.Body.Sender,
SenderOpenDingTalkID: payload.Body.SenderOpenDingTalkID,
ReadTime: payload.Body.ReadTime,
EventTime: payload.EventTime,
}, nil
}
func projectRecallEvent(ev transport.Event, base baseEventOutput, raw json.RawMessage) (any, error) {
var payload personalRecallPayload
if err := decodeRequiredPayload(raw, &payload); err != nil {
return ev, fmt.Errorf("decode personal recall payload: %w", err)
}
return RecallEventOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
MessageID: payload.Body.MessageID,
ConversationID: payload.Body.ConversationID,
Recaller: payload.Body.Recaller,
RecallerOpenDingTalkID: payload.Body.RecallerOpenDingTalkID,
Sender: payload.Body.Sender,
SenderOpenDingTalkID: payload.Body.SenderOpenDingTalkID,
RecallTime: payload.Body.RecallTime,
EventTime: payload.EventTime,
}, nil
}
func projectReactionEvent(ev transport.Event, base baseEventOutput, raw json.RawMessage) (any, error) {
var payload personalReactionPayload
if err := decodeRequiredPayload(raw, &payload); err != nil {
return ev, fmt.Errorf("decode personal reaction payload: %w", err)
}
return ReactionEventOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
MessageID: payload.Body.MessageID,
ConversationID: payload.Body.ConversationID,
Operator: payload.Body.Operator,
OperatorOpenDingTalkID: payload.Body.OperatorOpenDingTalkID,
ReactionName: payload.Body.ReactionName,
ReactionText: payload.Body.ReactionText,
OperationType: payload.Body.OperationType,
OperationTime: payload.Body.OperationTime,
Sender: payload.Body.Sender,
SenderOpenDingTalkID: payload.Body.SenderOpenDingTalkID,
EventTime: payload.EventTime,
}, nil
}
func decodeRequiredPayload(raw json.RawMessage, target any) error {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return fmt.Errorf("payload is missing")
}
var payloadObject map[string]json.RawMessage
if err := json.Unmarshal(trimmed, &payloadObject); err != nil {
return err
}
if len(payloadObject) == 0 {
return fmt.Errorf("payload is empty")
}
body, ok := payloadObject["body"]
if !ok {
return fmt.Errorf("payload body is missing")
}
body = bytes.TrimSpace(body)
if len(body) == 0 || bytes.Equal(body, []byte("null")) {
return fmt.Errorf("payload body is missing")
}
var bodyObject map[string]json.RawMessage
if err := json.Unmarshal(body, &bodyObject); err != nil {
return fmt.Errorf("decode payload body: %w", err)
}
if len(bodyObject) == 0 {
return fmt.Errorf("payload body is empty")
}
return json.Unmarshal(trimmed, target)
}
func decodePersonalEventData(raw string) (personalEventData, error) {
encoded := []byte(strings.TrimSpace(raw))
if len(encoded) == 0 {
return personalEventData{}, fmt.Errorf("empty data")
}
// Some gateways wrap the JSON object in one or more JSON strings. Peel
// those wrappers without changing the raw transport envelope.
for depth := 0; depth < 2; depth++ {
var quoted string
if err := json.Unmarshal(encoded, &quoted); err != nil {
break
}
encoded = []byte(strings.TrimSpace(quoted))
}
var data personalEventData
if err := json.Unmarshal(encoded, &data); err != nil {
return personalEventData{}, err
}
if data.EventKey == "" && data.EventID == "" && data.OccurredAtMS == 0 && data.SubID == "" && len(data.Payload) == 0 {
return personalEventData{}, fmt.Errorf("data is not a personal event object")
}
return data, nil
}
func outputSchema(eventKey string) map[string]any {
outputType := outputTypeForEvent(eventKey)
properties := make(map[string]any, outputType.NumField())
for i := 0; i < outputType.NumField(); i++ {
field := outputType.Field(i)
name := strings.Split(field.Tag.Get("json"), ",")[0]
if name == "" || name == "-" {
continue
}
property := map[string]any{
"type": schemaType(field.Type),
}
if description := field.Tag.Get("description"); description != "" {
property["description"] = description
}
if format := field.Tag.Get("format"); format != "" {
property["format"] = format
}
if field.Tag.Get("additional_properties") == "true" {
property["additionalProperties"] = true
}
if name == "type" {
property["enum"] = []string{eventKey}
}
properties[name] = property
}
return map[string]any{
"type": "object",
"properties": properties,
}
}
func outputTypeForEvent(eventKey string) reflect.Type {
switch {
case isMessageReceiveEvent(eventKey):
return reflect.TypeOf(MessageEventOutput{})
case isReadEvent(eventKey):
return reflect.TypeOf(ReadEventOutput{})
case isRecallEvent(eventKey):
return reflect.TypeOf(RecallEventOutput{})
case isReactionEvent(eventKey):
return reflect.TypeOf(ReactionEventOutput{})
default:
return reflect.TypeOf(baseEventOutput{})
}
}
func isReadEvent(eventKey string) bool {
return eventKey == EventReadO2O || eventKey == EventReadGroup
}
func isRecallEvent(eventKey string) bool {
return eventKey == EventRecallO2O || eventKey == EventRecallGroup
}
func isReactionEvent(eventKey string) bool {
return eventKey == EventReactionO2O || eventKey == EventReactionGroup
}
func schemaType(t reflect.Type) string {
switch t.Kind() {
case reflect.String:
return "string"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "integer"
case reflect.Map, reflect.Struct:
return "object"
case reflect.Slice, reflect.Array:
return "array"
case reflect.Bool:
return "boolean"
default:
return "object"
}
}
func firstNonEmptyOutput(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
+386
View File
@@ -0,0 +1,386 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package personal
import (
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
func personalMessageData(eventKey string) string {
return fmt.Sprintf(`{
"eventId":"data-event",
"eventKey":%q,
"occurredAtMs":1783483236995,
"subId":"data-sub",
"payload":{
"body":{
"createTime":"2026-07-08 12:00:35",
"sender":"测试用户甲",
"openMessageId":"msg-1",
"senderOpenDingTalkId":"open-user-1",
"openConversationId":"cid-1",
"content":"在吗"
},
"event_time":1783483235983
}
}`, eventKey)
}
func personalReadData(eventKey, messageID, conversationID string) string {
return fmt.Sprintf(`{
"eventId":"read-event",
"eventKey":%q,
"occurredAtMs":1784008412182,
"subId":"read-sub",
"payload":{
"bizid":"internal-bizid",
"body":{
"msgReadTime":"2026-07-14 13:53:31",
"openConversationId":%q,
"openMessageId":%q,
"reader":"测试用户乙",
"readerOpenDingTalkId":"reader-open-id",
"sender":"测试用户甲",
"senderOpenDingTalkId":"sender-open-id"
},
"clientId":"internal-client",
"corpid":"internal-corp",
"event_time":1784008411652,
"filterSubId":"internal-filter",
"uid":100001
}
}`, eventKey, conversationID, messageID)
}
func personalRecallData(eventKey, messageID, conversationID string) string {
return fmt.Sprintf(`{
"eventId":"recall-event",
"eventKey":%q,
"occurredAtMs":1784008592969,
"subId":"recall-sub",
"payload":{
"bizid":"internal-bizid",
"body":{
"msgRecallTime":"2026-07-14 13:56:32",
"openConversationId":%q,
"openMessageId":%q,
"recaller":"测试用户乙",
"recallerOpenDingTalkId":"recaller-open-id",
"sender":"测试用户乙",
"senderOpenDingTalkId":"sender-open-id"
},
"clientId":"internal-client",
"corpid":"internal-corp",
"event_time":1784008592766,
"filterSubId":"internal-filter",
"uid":100001
}
}`, eventKey, conversationID, messageID)
}
func personalReactionData(eventKey, messageID, conversationID string) string {
return fmt.Sprintf(`{
"eventId":"reaction-event",
"eventKey":%q,
"occurredAtMs":1784008680072,
"subId":"reaction-sub",
"payload":{
"bizid":"internal-bizid",
"body":{
"emotionName":"微笑",
"emotionText":"微笑",
"openConversationId":%q,
"openSourceMessageId":%q,
"oper":"测试用户乙",
"operOpenDingtalkId":"operator-open-id",
"operateTime":"2026-07-14 13:57:59",
"operateType":"add",
"sender":"测试用户甲",
"senderOpenDingTalkId":"sender-open-id"
},
"clientId":"internal-client",
"corpid":"internal-corp",
"event_time":1784008679217,
"filterSubId":"internal-filter",
"uid":100001
}
}`, eventKey, conversationID, messageID)
}
func TestCrossPlatformCoverageProjectOutputMessageEvents(t *testing.T) {
for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser} {
t.Run(eventKey, func(t *testing.T) {
projected, err := ProjectOutput(transport.Event{
Type: transport.FrameTypeEvent,
EventID: "outer-event",
EventBornTime: 11,
EventType: eventKey,
SubscribeID: "outer-sub",
Data: personalMessageData(eventKey),
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got, ok := projected.(MessageEventOutput)
if !ok {
t.Fatalf("ProjectOutput() type = %T", projected)
}
want := MessageEventOutput{
Type: eventKey,
EventID: "data-event",
Timestamp: 1783483236995,
SubscribeID: "outer-sub",
MessageID: "msg-1",
ConversationID: "cid-1",
Sender: "测试用户甲",
SenderOpenDingTalkID: "open-user-1",
Content: "在吗",
CreateTime: "2026-07-08 12:00:35",
EventTime: 1783483235983,
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ProjectOutput() = %#v, want %#v", got, want)
}
})
}
}
func TestCrossPlatformCoverageProjectOutputDecodesWrappedJSONString(t *testing.T) {
wrapped, err := json.Marshal(personalMessageData(EventSingleChat))
if err != nil {
t.Fatal(err)
}
projected, err := ProjectOutput(transport.Event{Data: string(wrapped)})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got := projected.(MessageEventOutput)
if got.Content != "在吗" || got.Type != EventSingleChat || got.SubscribeID != "data-sub" || got.EventID != "data-event" {
t.Fatalf("ProjectOutput() = %#v", got)
}
}
func TestCrossPlatformCoverageProjectOutputFallsBackToTransportFields(t *testing.T) {
projected, err := ProjectOutput(transport.Event{
EventID: "outer-event",
EventBornTime: 123,
EventType: EventSingleChat,
SubscribeID: "outer-sub",
Data: `{"payload":{"body":{"content":"hello"}}}`,
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got := projected.(MessageEventOutput)
if got.EventID != "outer-event" || got.Timestamp != 123 || got.SubscribeID != "outer-sub" || got.Type != EventSingleChat {
t.Fatalf("fallback fields = %#v", got)
}
}
func TestCrossPlatformCoverageProjectOutputReadEvents(t *testing.T) {
for _, eventKey := range []string{EventReadO2O, EventReadGroup} {
t.Run(eventKey, func(t *testing.T) {
projected, err := ProjectOutput(transport.Event{
EventType: eventKey,
Data: personalReadData(eventKey, "read-message", "read-conversation"),
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
want := ReadEventOutput{
Type: eventKey,
EventID: "read-event",
Timestamp: 1784008412182,
SubscribeID: "read-sub",
MessageID: "read-message",
ConversationID: "read-conversation",
Reader: "测试用户乙",
ReaderOpenDingTalkID: "reader-open-id",
Sender: "测试用户甲",
SenderOpenDingTalkID: "sender-open-id",
ReadTime: "2026-07-14 13:53:31",
EventTime: 1784008411652,
}
if !reflect.DeepEqual(projected, want) {
t.Fatalf("ProjectOutput() = %#v, want %#v", projected, want)
}
assertNoInternalActionFields(t, projected)
})
}
}
func TestCrossPlatformCoverageProjectOutputRecallEvents(t *testing.T) {
for _, eventKey := range []string{EventRecallO2O, EventRecallGroup} {
t.Run(eventKey, func(t *testing.T) {
projected, err := ProjectOutput(transport.Event{
EventType: eventKey,
Data: personalRecallData(eventKey, "recall-message", "recall-conversation"),
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
want := RecallEventOutput{
Type: eventKey,
EventID: "recall-event",
Timestamp: 1784008592969,
SubscribeID: "recall-sub",
MessageID: "recall-message",
ConversationID: "recall-conversation",
Recaller: "测试用户乙",
RecallerOpenDingTalkID: "recaller-open-id",
Sender: "测试用户乙",
SenderOpenDingTalkID: "sender-open-id",
RecallTime: "2026-07-14 13:56:32",
EventTime: 1784008592766,
}
if !reflect.DeepEqual(projected, want) {
t.Fatalf("ProjectOutput() = %#v, want %#v", projected, want)
}
assertNoInternalActionFields(t, projected)
})
}
}
func TestCrossPlatformCoverageProjectOutputReactionEvents(t *testing.T) {
for _, eventKey := range []string{EventReactionO2O, EventReactionGroup} {
t.Run(eventKey, func(t *testing.T) {
projected, err := ProjectOutput(transport.Event{
EventType: eventKey,
Data: personalReactionData(eventKey, "reaction-message", "reaction-conversation"),
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
want := ReactionEventOutput{
Type: eventKey,
EventID: "reaction-event",
Timestamp: 1784008680072,
SubscribeID: "reaction-sub",
MessageID: "reaction-message",
ConversationID: "reaction-conversation",
Operator: "测试用户乙",
OperatorOpenDingTalkID: "operator-open-id",
ReactionName: "微笑",
ReactionText: "微笑",
OperationType: "add",
OperationTime: "2026-07-14 13:57:59",
Sender: "测试用户甲",
SenderOpenDingTalkID: "sender-open-id",
EventTime: 1784008679217,
}
if !reflect.DeepEqual(projected, want) {
t.Fatalf("ProjectOutput() = %#v, want %#v", projected, want)
}
assertNoInternalActionFields(t, projected)
})
}
}
func TestCrossPlatformCoverageProjectOutputReactionRejectsLegacyOperatorOpenIDSpellings(t *testing.T) {
for _, legacyField := range []string{"operOpenDingtlkId", "operOpenDingTalkId"} {
t.Run(legacyField, func(t *testing.T) {
data := strings.Replace(
personalReactionData(EventReactionO2O, "reaction-message", "reaction-conversation"),
"operOpenDingtalkId",
legacyField,
1,
)
projected, err := ProjectOutput(transport.Event{EventType: EventReactionO2O, Data: data})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got := projected.(ReactionEventOutput)
if got.OperatorOpenDingTalkID != "" {
t.Fatalf("operator_open_dingtalk_id = %q, want empty for legacy field %s", got.OperatorOpenDingTalkID, legacyField)
}
})
}
}
func assertNoInternalActionFields(t *testing.T, projected any) {
t.Helper()
raw, err := json.Marshal(projected)
if err != nil {
t.Fatal(err)
}
for _, field := range []string{"payload", "uid", "corpid", "clientId", "filterSubId", "bizid"} {
if strings.Contains(string(raw), `"`+field+`"`) {
t.Fatalf("projected output leaked internal field %q: %s", field, raw)
}
}
}
func TestCrossPlatformCoverageProjectOutputRejectsEmptyPayloads(t *testing.T) {
eventKeys := []string{
EventMention,
EventSingleChat,
EventInChat,
EventFromUser,
EventReadO2O,
EventReadGroup,
EventRecallO2O,
EventRecallGroup,
EventReactionO2O,
EventReactionGroup,
}
payloads := []struct {
name string
json string
}{
{name: "missing", json: ""},
{name: "null", json: `,"payload":null`},
{name: "empty object", json: `,"payload":{}`},
{name: "missing body", json: `,"payload":{"event_time":1}`},
{name: "null body", json: `,"payload":{"body":null}`},
{name: "empty body", json: `,"payload":{"body":{}}`},
}
for _, eventKey := range eventKeys {
for _, payload := range payloads {
t.Run(eventKey+"/"+payload.name, func(t *testing.T) {
ev := transport.Event{
EventID: "outer-event",
EventType: eventKey,
Data: fmt.Sprintf(`{"eventKey":%q%s}`, eventKey, payload.json),
}
projected, err := ProjectOutput(ev)
if err == nil {
t.Fatal("ProjectOutput() error = nil, want payload validation error")
}
got, ok := projected.(transport.Event)
if !ok || !reflect.DeepEqual(got, ev) {
t.Fatalf("ProjectOutput() fallback = %#v, want %#v", projected, ev)
}
})
}
}
}
func TestCrossPlatformCoverageProjectOutputMalformedDataReturnsRawEnvelope(t *testing.T) {
ev := transport.Event{EventID: "outer-event", Data: "not-json"}
projected, err := ProjectOutput(ev)
if err == nil {
t.Fatal("ProjectOutput() error = nil, want decode error")
}
got, ok := projected.(transport.Event)
if !ok || !reflect.DeepEqual(got, ev) {
t.Fatalf("ProjectOutput() fallback = %#v", projected)
}
}
+204 -112
View File
@@ -23,10 +23,16 @@ import (
)
const (
EventMention = "user_im_message_receive_at"
EventSingleChat = "user_im_message_receive_o2o"
EventInChat = "user_im_message_receive_group"
EventFromUser = "user_im_message_receive_user"
EventMention = "user_im_message_receive_at"
EventSingleChat = "user_im_message_receive_o2o"
EventInChat = "user_im_message_receive_group"
EventFromUser = "user_im_message_receive_user"
EventReadO2O = "user_im_message_read_o2o"
EventReadGroup = "user_im_message_read_group"
EventRecallO2O = "user_im_message_recall_o2o"
EventRecallGroup = "user_im_message_recall_group"
EventReactionO2O = "user_im_message_reaction_o2o"
EventReactionGroup = "user_im_message_reaction_group"
)
const (
@@ -35,32 +41,40 @@ const (
)
type Definition struct {
EventKey string `json:"event_key"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Category string `json:"category"`
RuleType string `json:"rule_type"`
Status string `json:"status"`
RequiredParams []string `json:"required_params"`
Auth map[string]any `json:"auth,omitempty"`
Public bool `json:"-"`
EventKey string `json:"event_key"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Category string `json:"category"`
RuleType string `json:"rule_type"`
Status string `json:"status"`
RequiredParams []string `json:"required_params"`
Constraints *ParameterConstraints `json:"constraints,omitempty"`
Auth map[string]any `json:"auth,omitempty"`
Public bool `json:"-"`
}
type ParameterConstraints struct {
RequireOneOf [][]string `json:"require_one_of,omitempty"`
MutuallyExclusive [][]string `json:"mutually_exclusive,omitempty"`
}
type SchemaDocument struct {
EventKey string `json:"event_key"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Category string `json:"category"`
RuleType string `json:"rule_type"`
RequiredParams []string `json:"required_params"`
JQRootPath string `json:"jq_root_path"`
Schema map[string]any `json:"schema"`
EventKey string `json:"event_key"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Category string `json:"category"`
RuleType string `json:"rule_type"`
RequiredParams []string `json:"required_params"`
Constraints *ParameterConstraints `json:"constraints,omitempty"`
JQRootPath string `json:"jq_root_path"`
Schema map[string]any `json:"schema"`
}
type RuleOptions struct {
RuleType string
UserID string
GroupID string
RuleType string
UserID string
OpenDingTalkID string
GroupID string
}
type SchemaPendingError struct {
@@ -90,7 +104,8 @@ var definitions = []Definition{
Category: "im",
RuleType: "singleChat",
Status: StatusEnabled,
RequiredParams: []string{"user"},
RequiredParams: nil,
Constraints: targetUIDConstraints(),
Auth: map[string]any{"identity": "user"},
Public: true,
},
@@ -108,25 +123,105 @@ var definitions = []Definition{
{
EventKey: EventFromUser,
DisplayName: "指定发送人消息",
Description: "当前用户收到的特别关注用户的消息",
Description: "当前用户收到的指定用户发送的消息",
Category: "im",
RuleType: "sender",
Status: StatusEnabled,
RequiredParams: []string{"user"},
RequiredParams: nil,
Constraints: targetUIDConstraints(),
Auth: map[string]any{"identity": "user"},
Public: false,
Public: true,
},
{
EventKey: EventReadO2O,
DisplayName: "指定单聊消息已读",
Description: "当前用户在指定单聊中发送的消息被对方已读",
Category: "im",
RuleType: "singleChat",
Status: StatusEnabled,
RequiredParams: nil,
Constraints: targetUIDConstraints(),
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventReadGroup,
DisplayName: "指定群消息已读",
Description: "当前用户在指定群聊中发送的消息被已读",
Category: "im",
RuleType: "group",
Status: StatusEnabled,
RequiredParams: []string{"group"},
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventRecallO2O,
DisplayName: "指定单聊消息撤回",
Description: "指定单聊中的消息被撤回",
Category: "im",
RuleType: "singleChat",
Status: StatusEnabled,
RequiredParams: nil,
Constraints: targetUIDConstraints(),
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventRecallGroup,
DisplayName: "指定群消息撤回",
Description: "指定群聊中的消息被撤回",
Category: "im",
RuleType: "group",
Status: StatusEnabled,
RequiredParams: []string{"group"},
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventReactionO2O,
DisplayName: "指定单聊消息表情回应",
Description: "指定单聊中的消息收到表情回应(贴表情)",
Category: "im",
RuleType: "singleChat",
Status: StatusEnabled,
RequiredParams: nil,
Constraints: targetUIDConstraints(),
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventReactionGroup,
DisplayName: "指定群消息表情回应",
Description: "指定群聊中的消息收到表情回应(贴表情)",
Category: "im",
RuleType: "group",
Status: StatusEnabled,
RequiredParams: []string{"group"},
Auth: map[string]any{"identity": "user"},
Public: true,
},
}
func targetUIDConstraints() *ParameterConstraints {
return &ParameterConstraints{
RequireOneOf: [][]string{{"user", "open-dingtalk-id"}},
MutuallyExclusive: [][]string{{"user", "open-dingtalk-id"}},
}
}
func Definitions() []Definition {
out := append([]Definition(nil), definitions...)
out := make([]Definition, 0, len(definitions))
for _, def := range definitions {
out = append(out, cloneDefinition(def))
}
return out
}
func Lookup(eventKey string) (Definition, bool) {
for _, def := range definitions {
if def.EventKey == eventKey {
return def, true
return cloneDefinition(def), true
}
}
return Definition{}, false
@@ -157,7 +252,7 @@ func Catalog(category string, enabledOnly, includePending bool) []Definition {
if !includePending && def.Status == StatusPending {
continue
}
out = append(out, def)
out = append(out, cloneDefinition(def))
}
return out
}
@@ -172,11 +267,46 @@ func BuildSchemaDocument(def Definition) SchemaDocument {
Category: def.Category,
RuleType: def.RuleType,
RequiredParams: requiredParams,
JQRootPath: ".data | fromjson",
Schema: personalMessageSchema(def.EventKey),
Constraints: cloneParameterConstraints(def.Constraints),
JQRootPath: ".",
Schema: outputSchema(def.EventKey),
}
}
func cloneDefinition(def Definition) Definition {
def.RequiredParams = append([]string(nil), def.RequiredParams...)
def.Constraints = cloneParameterConstraints(def.Constraints)
if def.Auth != nil {
def.Auth = cloneMap(def.Auth)
}
return def
}
func cloneParameterConstraints(in *ParameterConstraints) *ParameterConstraints {
if in == nil {
return nil
}
cloneGroups := func(groups [][]string) [][]string {
out := make([][]string, len(groups))
for i, group := range groups {
out[i] = append([]string(nil), group...)
}
return out
}
return &ParameterConstraints{
RequireOneOf: cloneGroups(in.RequireOneOf),
MutuallyExclusive: cloneGroups(in.MutuallyExclusive),
}
}
func cloneMap(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for key, value := range in {
out[key] = value
}
return out
}
func BuildRuleParam(eventKey string, opts RuleOptions) (ruleType string, ruleParam map[string]any, err error) {
def, ok := Lookup(eventKey)
if !ok {
@@ -189,44 +319,33 @@ func BuildRuleParam(eventKey string, opts RuleOptions) (ruleType string, rulePar
return "", nil, &SchemaPendingError{EventKey: eventKey}
}
userID := strings.TrimSpace(opts.UserID)
openDingTalkID := strings.TrimSpace(opts.OpenDingTalkID)
groupID := strings.TrimSpace(opts.GroupID)
switch def.RuleType {
case "at":
if userID != "" {
return "", nil, fmt.Errorf("--user is only supported for %s", EventSingleChat)
return "", nil, fmt.Errorf("--user is not supported for %s", eventKey)
}
if openDingTalkID != "" {
return "", nil, fmt.Errorf("--open-dingtalk-id is not supported for %s", eventKey)
}
if groupID != "" {
return "", nil, fmt.Errorf("--group is only supported for %s", EventInChat)
return "", nil, fmt.Errorf("--group is not supported for %s", eventKey)
}
return def.RuleType, map[string]any{}, nil
case "singleChat":
if groupID != "" {
return "", nil, fmt.Errorf("--group is only supported for %s", EventInChat)
}
if userID == "" {
return "", nil, fmt.Errorf("--user is required")
}
return def.RuleType, map[string]any{
"targetUid": userID,
"targetUidType": "staffId",
}, nil
return buildTargetUIDRuleParam(def.RuleType, eventKey, userID, openDingTalkID, groupID)
case "sender":
if groupID != "" {
return "", nil, fmt.Errorf("--group is only supported for %s", EventInChat)
}
if userID == "" {
return "", nil, fmt.Errorf("--user is required")
}
return def.RuleType, map[string]any{
"targetUid": userID,
"targetUidType": "staffId",
}, nil
return buildTargetUIDRuleParam(def.RuleType, eventKey, userID, openDingTalkID, groupID)
case "group":
if userID != "" {
return "", nil, fmt.Errorf("--user is only supported for %s", EventSingleChat)
return "", nil, fmt.Errorf("--user is not supported for %s; use --group", eventKey)
}
if openDingTalkID != "" {
return "", nil, fmt.Errorf("--open-dingtalk-id is not supported for %s; use --group", eventKey)
}
if groupID == "" {
return "", nil, fmt.Errorf("--group is required")
return "", nil, fmt.Errorf("--group is required for %s", eventKey)
}
return def.RuleType, map[string]any{
"openConversationId": groupID,
@@ -236,6 +355,28 @@ func BuildRuleParam(eventKey string, opts RuleOptions) (ruleType string, rulePar
}
}
func buildTargetUIDRuleParam(ruleType, eventKey, userID, openDingTalkID, groupID string) (string, map[string]any, error) {
if groupID != "" {
return "", nil, fmt.Errorf("--group is not supported for %s; use --user or --open-dingtalk-id", eventKey)
}
if userID != "" && openDingTalkID != "" {
return "", nil, fmt.Errorf("--user and --open-dingtalk-id are mutually exclusive for %s", eventKey)
}
if userID == "" && openDingTalkID == "" {
return "", nil, fmt.Errorf("one of --user or --open-dingtalk-id is required for %s", eventKey)
}
if openDingTalkID != "" {
return ruleType, map[string]any{
"targetUid": openDingTalkID,
"targetUidType": "openDingtalkId",
}, nil
}
return ruleType, map[string]any{
"targetUid": userID,
"targetUidType": "staffId",
}, nil
}
func BuildFilter(filterJSON string, queryCSV string) (any, string, error) {
var parts []any
filterJSON = strings.TrimSpace(filterJSON)
@@ -337,61 +478,12 @@ func normalizeFilterAliases(v any) any {
}
}
func personalMessageSchema(eventKey string) map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"type": map[string]any{
"type": "string",
"description": "事件类型,固定为当前 event_key",
"enum": []string{eventKey},
},
"event_id": map[string]any{
"type": "string",
"description": "事件 ID,可用于去重",
},
"timestamp": map[string]any{
"type": "integer",
"description": "事件发生时间戳,对应 occurredAtMs",
"format": "timestamp_ms",
},
"subscribe_id": map[string]any{
"type": "string",
"description": "订阅 ID,对应 subId",
},
"message_id": map[string]any{
"type": "string",
"description": "开放消息 ID,对应 payload.body.openMessageId",
"format": "open_message_id",
},
"conversation_id": map[string]any{
"type": "string",
"description": "会话 ID,对应 payload.body.openConversationId",
"format": "open_conversation_id",
},
"sender": map[string]any{
"type": "string",
"description": "发送人展示名,对应 payload.body.sender",
},
"sender_open_dingtalk_id": map[string]any{
"type": "string",
"description": "发送人开放 ID,对应 payload.body.senderOpenDingTalkId",
"format": "open_dingtalk_id",
},
"content": map[string]any{
"type": "string",
"description": "消息正文,对应 payload.body.content",
},
"create_time": map[string]any{
"type": "string",
"description": "消息创建时间,对应 payload.body.createTime",
},
"event_time": map[string]any{
"type": "integer",
"description": "消息事件时间戳,对应 payload.event_time",
"format": "timestamp_ms",
},
},
func isMessageReceiveEvent(eventKey string) bool {
switch eventKey {
case EventMention, EventSingleChat, EventInChat, EventFromUser:
return true
default:
return false
}
}
+263 -15
View File
@@ -33,18 +33,25 @@ func TestCatalogEnabledEvents(t *testing.T) {
EventMention,
EventSingleChat,
EventInChat,
EventFromUser,
EventReadO2O,
EventReadGroup,
EventRecallO2O,
EventRecallGroup,
EventReactionO2O,
EventReactionGroup,
}
if !reflect.DeepEqual(keys, want) {
t.Fatalf("keys = %#v, want %#v", keys, want)
}
}
func TestEventFromUserRemainsInternalButNotPublic(t *testing.T) {
func TestEventFromUserIsPublic(t *testing.T) {
if _, ok := Lookup(EventFromUser); !ok {
t.Fatalf("Lookup(%q) failed, want internal definition retained", EventFromUser)
t.Fatalf("Lookup(%q) failed", EventFromUser)
}
if IsPublic(EventFromUser) {
t.Fatalf("IsPublic(%q) = true, want hidden", EventFromUser)
if !IsPublic(EventFromUser) {
t.Fatalf("IsPublic(%q) = false, want public", EventFromUser)
}
}
@@ -54,6 +61,16 @@ func TestLegacyEventKeysAreUnknown(t *testing.T) {
"im_message_receive_o2o",
"im_message_receive_group",
"im_message_receive_user",
"im_message_read_o2o",
"im_message_read_group",
"im_message_recall_o2o",
"im_message_recall_group",
"im_message_emotion_o2o",
"im_message_emotion_group",
"im_message_reaction_o2o",
"im_message_reaction_group",
"user_im_message_emotion_o2o",
"user_im_message_emotion_group",
}
for _, key := range legacyKeys {
if _, ok := Lookup(key); ok {
@@ -140,8 +157,8 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
t.Fatalf("schema for %s leaked %q: %s", eventKey, leaked, out)
}
}
if doc.JQRootPath != ".data | fromjson" {
t.Fatalf("jq_root_path = %q, want .data | fromjson", doc.JQRootPath)
if doc.JQRootPath != "." {
t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath)
}
if doc.RequiredParams == nil {
t.Fatalf("required_params = nil, want empty slice")
@@ -150,13 +167,151 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
if !ok {
t.Fatalf("schema.properties = %#v, want object", doc.Schema["properties"])
}
if _, ok := props["content"].(map[string]any); !ok {
t.Fatalf("schema.properties.content = %#v, want object", props["content"])
wantProperties := []string{
"type", "event_id", "timestamp", "subscribe_id", "message_id",
"conversation_id", "sender", "sender_open_dingtalk_id", "content",
"create_time", "event_time",
}
if len(props) != len(wantProperties) {
t.Fatalf("schema.properties = %#v, want exactly %d DTO fields", props, len(wantProperties))
}
for _, name := range wantProperties {
if _, ok := props[name].(map[string]any); !ok {
t.Fatalf("schema.properties.%s = %#v, want object", name, props[name])
}
}
})
}
}
func TestTargetUIDEventSchemasRequireUserOrOpenDingTalkID(t *testing.T) {
wantConstraints := &ParameterConstraints{
RequireOneOf: [][]string{{"user", "open-dingtalk-id"}},
MutuallyExclusive: [][]string{{"user", "open-dingtalk-id"}},
}
for _, eventKey := range []string{
EventSingleChat,
EventFromUser,
EventReadO2O,
EventRecallO2O,
EventReactionO2O,
} {
t.Run(eventKey, func(t *testing.T) {
def, ok := Lookup(eventKey)
if !ok {
t.Fatalf("Lookup(%q) failed", eventKey)
}
if len(def.RequiredParams) != 0 {
t.Fatalf("required_params = %#v, want no unconditional parameters", def.RequiredParams)
}
if !reflect.DeepEqual(def.Constraints, wantConstraints) {
t.Fatalf("constraints = %#v, want %#v", def.Constraints, wantConstraints)
}
doc := BuildSchemaDocument(def)
if !reflect.DeepEqual(doc.Constraints, wantConstraints) {
t.Fatalf("schema constraints = %#v, want %#v", doc.Constraints, wantConstraints)
}
})
}
mention, _ := Lookup(EventMention)
if len(mention.RequiredParams) != 0 {
t.Fatalf("mention required_params = %#v, want none", mention.RequiredParams)
}
if mention.Constraints != nil {
t.Fatalf("mention constraints = %#v, want none", mention.Constraints)
}
group, _ := Lookup(EventInChat)
if want := []string{"group"}; !reflect.DeepEqual(group.RequiredParams, want) {
t.Fatalf("group required_params = %#v, want %#v", group.RequiredParams, want)
}
if group.Constraints != nil {
t.Fatalf("group constraints = %#v, want none", group.Constraints)
}
}
func TestDefinitionCopiesDoNotMutateRegistryConstraints(t *testing.T) {
def, ok := Lookup(EventSingleChat)
if !ok {
t.Fatalf("Lookup(%q) failed", EventSingleChat)
}
def.Constraints.RequireOneOf[0][0] = "mutated"
again, _ := Lookup(EventSingleChat)
if got := again.Constraints.RequireOneOf[0][0]; got != "user" {
t.Fatalf("registry constraint mutated through lookup copy: %q", got)
}
}
func TestActionSchemaDocumentsMatchOutputDTOs(t *testing.T) {
tests := []struct {
name string
eventKeys []string
properties []string
}{
{
name: "read",
eventKeys: []string{EventReadO2O, EventReadGroup},
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "message_id",
"conversation_id", "reader", "reader_open_dingtalk_id", "sender",
"sender_open_dingtalk_id", "read_time", "event_time",
},
},
{
name: "recall",
eventKeys: []string{EventRecallO2O, EventRecallGroup},
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "message_id",
"conversation_id", "recaller", "recaller_open_dingtalk_id", "sender",
"sender_open_dingtalk_id", "recall_time", "event_time",
},
},
{
name: "reaction",
eventKeys: []string{EventReactionO2O, EventReactionGroup},
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "message_id",
"conversation_id", "operator", "operator_open_dingtalk_id", "reaction_name",
"reaction_text", "operation_type", "operation_time", "sender",
"sender_open_dingtalk_id", "event_time",
},
},
}
for _, tt := range tests {
for _, eventKey := range tt.eventKeys {
t.Run(tt.name+"/"+eventKey, func(t *testing.T) {
def, ok := Lookup(eventKey)
if !ok {
t.Fatalf("Lookup(%q) failed", eventKey)
}
doc := BuildSchemaDocument(def)
if doc.JQRootPath != "." {
t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath)
}
props, ok := doc.Schema["properties"].(map[string]any)
if !ok {
t.Fatalf("schema.properties = %#v", doc.Schema["properties"])
}
if len(props) != len(tt.properties) {
t.Fatalf("schema properties = %#v, want exactly %d DTO fields", props, len(tt.properties))
}
for _, name := range tt.properties {
if _, ok := props[name].(map[string]any); !ok {
t.Fatalf("schema.properties.%s = %#v, want object", name, props[name])
}
}
for _, internal := range []string{"payload", "uid", "corpid", "clientId", "filterSubId", "bizid"} {
if _, ok := props[internal]; ok {
t.Fatalf("schema exposed internal property %q", internal)
}
}
})
}
}
}
func TestBuildRuleParamMention(t *testing.T) {
rule, param, err := BuildRuleParam(EventMention, RuleOptions{})
if err != nil {
@@ -172,8 +327,8 @@ func TestBuildRuleParamMention(t *testing.T) {
func TestBuildRuleParamSingleChatRequiresPeer(t *testing.T) {
_, _, err := BuildRuleParam(EventSingleChat, RuleOptions{})
if err == nil || !strings.Contains(err.Error(), "--user is required") {
t.Fatalf("error = %v, want user requirement", err)
if err == nil || !strings.Contains(err.Error(), "one of --user or --open-dingtalk-id is required") {
t.Fatalf("error = %v, want target identity requirement", err)
}
}
@@ -190,9 +345,22 @@ func TestBuildRuleParamSingleChatUserIDMapsToStaffID(t *testing.T) {
}
}
func TestBuildRuleParamSingleChatOpenDingTalkID(t *testing.T) {
rule, param, err := BuildRuleParam(EventSingleChat, RuleOptions{OpenDingTalkID: "open-user-1"})
if err != nil {
t.Fatalf("BuildRuleParam() error = %v", err)
}
if rule != "singleChat" {
t.Fatalf("rule = %q, want singleChat", rule)
}
if param["targetUidType"] != "openDingtalkId" || param["targetUid"] != "open-user-1" {
t.Fatalf("param = %#v", param)
}
}
func TestBuildRuleParamSender(t *testing.T) {
_, _, err := BuildRuleParam(EventFromUser, RuleOptions{})
if err == nil || !strings.Contains(err.Error(), "--user is required") {
if err == nil || !strings.Contains(err.Error(), "one of --user or --open-dingtalk-id is required") {
t.Fatalf("error = %v, want sender requirement", err)
}
@@ -206,6 +374,14 @@ func TestBuildRuleParamSender(t *testing.T) {
if param["targetUidType"] != "staffId" || param["targetUid"] != "staff-1" {
t.Fatalf("param = %#v", param)
}
rule, param, err = BuildRuleParam(EventFromUser, RuleOptions{OpenDingTalkID: "open-user-1"})
if err != nil {
t.Fatalf("BuildRuleParam(openDingtalkId) error = %v", err)
}
if rule != "sender" || param["targetUidType"] != "openDingtalkId" || param["targetUid"] != "open-user-1" {
t.Fatalf("openDingtalkId rule = %q, param = %#v", rule, param)
}
}
func TestBuildRuleParamGroup(t *testing.T) {
@@ -226,16 +402,73 @@ func TestBuildRuleParamGroup(t *testing.T) {
}
}
func TestBuildRuleParamActionEvents(t *testing.T) {
for _, eventKey := range []string{EventReadO2O, EventRecallO2O, EventReactionO2O} {
t.Run(eventKey, func(t *testing.T) {
if _, _, err := BuildRuleParam(eventKey, RuleOptions{}); err == nil || !strings.Contains(err.Error(), "one of --user or --open-dingtalk-id is required for "+eventKey) {
t.Fatalf("missing target identity error = %v", err)
}
if _, _, err := BuildRuleParam(eventKey, RuleOptions{GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--group is not supported for "+eventKey) {
t.Fatalf("wrong group error = %v", err)
}
rule, param, err := BuildRuleParam(eventKey, RuleOptions{UserID: "staff-1"})
if err != nil {
t.Fatal(err)
}
if rule != "singleChat" || param["targetUid"] != "staff-1" || param["targetUidType"] != "staffId" {
t.Fatalf("rule = %q, param = %#v", rule, param)
}
rule, param, err = BuildRuleParam(eventKey, RuleOptions{OpenDingTalkID: "open-user-1"})
if err != nil {
t.Fatal(err)
}
if rule != "singleChat" || param["targetUid"] != "open-user-1" || param["targetUidType"] != "openDingtalkId" {
t.Fatalf("openDingtalkId rule = %q, param = %#v", rule, param)
}
})
}
for _, eventKey := range []string{EventReadGroup, EventRecallGroup, EventReactionGroup} {
t.Run(eventKey, func(t *testing.T) {
if _, _, err := BuildRuleParam(eventKey, RuleOptions{}); err == nil || !strings.Contains(err.Error(), "--group is required for "+eventKey) {
t.Fatalf("missing group error = %v", err)
}
if _, _, err := BuildRuleParam(eventKey, RuleOptions{UserID: "staff-1"}); err == nil || !strings.Contains(err.Error(), "--user is not supported for "+eventKey) {
t.Fatalf("wrong user error = %v", err)
}
if _, _, err := BuildRuleParam(eventKey, RuleOptions{OpenDingTalkID: "open-user-1"}); err == nil || !strings.Contains(err.Error(), "--open-dingtalk-id is not supported for "+eventKey) {
t.Fatalf("wrong openDingtalkId error = %v", err)
}
rule, param, err := BuildRuleParam(eventKey, RuleOptions{GroupID: "cid-1"})
if err != nil {
t.Fatal(err)
}
if rule != "group" || param["openConversationId"] != "cid-1" {
t.Fatalf("rule = %q, param = %#v", rule, param)
}
})
}
}
func TestBuildRuleParamRejectsWrongScopedFlags(t *testing.T) {
if _, _, err := BuildRuleParam(EventMention, RuleOptions{UserID: "staff-1"}); err == nil || !strings.Contains(err.Error(), "--user is only supported") {
if _, _, err := BuildRuleParam(EventMention, RuleOptions{UserID: "staff-1"}); err == nil || !strings.Contains(err.Error(), "--user is not supported for "+EventMention) {
t.Fatalf("mention with user error = %v, want unsupported user", err)
}
if _, _, err := BuildRuleParam(EventSingleChat, RuleOptions{UserID: "staff-1", GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--group is only supported") {
if _, _, err := BuildRuleParam(EventSingleChat, RuleOptions{UserID: "staff-1", GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--group is not supported for "+EventSingleChat) {
t.Fatalf("singleChat with group error = %v, want unsupported group", err)
}
if _, _, err := BuildRuleParam(EventInChat, RuleOptions{UserID: "staff-1", GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--user is only supported") {
if _, _, err := BuildRuleParam(EventSingleChat, RuleOptions{UserID: "staff-1", OpenDingTalkID: "open-user-1"}); err == nil || !strings.Contains(err.Error(), "--user and --open-dingtalk-id are mutually exclusive for "+EventSingleChat) {
t.Fatalf("singleChat with both identities error = %v, want mutually exclusive", err)
}
if _, _, err := BuildRuleParam(EventMention, RuleOptions{OpenDingTalkID: "open-user-1"}); err == nil || !strings.Contains(err.Error(), "--open-dingtalk-id is not supported for "+EventMention) {
t.Fatalf("mention with openDingtalkId error = %v, want unsupported openDingtalkId", err)
}
if _, _, err := BuildRuleParam(EventInChat, RuleOptions{UserID: "staff-1", GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--user is not supported for "+EventInChat) {
t.Fatalf("group with user error = %v, want unsupported user", err)
}
if _, _, err := BuildRuleParam(EventInChat, RuleOptions{OpenDingTalkID: "open-user-1", GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--open-dingtalk-id is not supported for "+EventInChat) {
t.Fatalf("group with openDingtalkId error = %v, want unsupported openDingtalkId", err)
}
}
func TestBuildFilterQueryAndJSON(t *testing.T) {
@@ -262,10 +495,25 @@ func TestBuildFilterQueryAndJSON(t *testing.T) {
func TestIdempotencyKeyUsesLocalIdentityKey(t *testing.T) {
left := Identity{LocalSubject: "refresh:left", ClientID: "client-1", SourceID: "open"}
right := Identity{LocalSubject: "refresh:right", ClientID: "client-1", SourceID: "open"}
ruleParam := map[string]any{"targetUid": "507971", "targetUidType": "staffId"}
ruleParam := map[string]any{"targetUid": "test-user-001", "targetUidType": "staffId"}
leftKey := IdempotencyKey(left, EventSingleChat, "singleChat", ruleParam, "")
rightKey := IdempotencyKey(right, EventSingleChat, "singleChat", ruleParam, "")
if leftKey == rightKey {
t.Fatalf("idempotency key collapsed for different local subjects: %s", leftKey)
}
}
func TestIdempotencyKeySeparatesTargetUIDTypes(t *testing.T) {
identity := Identity{LocalSubject: "refresh:subject", ClientID: "client-1", SourceID: "open"}
staffIDKey := IdempotencyKey(identity, EventSingleChat, "singleChat", map[string]any{
"targetUid": "same-value",
"targetUidType": "staffId",
}, "")
openIDKey := IdempotencyKey(identity, EventSingleChat, "singleChat", map[string]any{
"targetUid": "same-value",
"targetUidType": "openDingtalkId",
}, "")
if staffIDKey == openIDKey {
t.Fatalf("idempotency key collapsed for different targetUidType: %s", staffIDKey)
}
}
+76
View File
@@ -615,6 +615,82 @@ func TestPersonalSourceParsedHeadersPassNormalBusFilter(t *testing.T) {
}
}
func TestPersonalSourceActionEventPassesNormalBusFilter(t *testing.T) {
const (
eventKey = "user_im_message_reaction_group"
subscribeID = "sub-reaction-group"
)
src := personalSourceForRawEventTests()
raw := src.rawEventFromDataFrame(&payload.DataFrame{
Headers: payload.DataFrameHeader{
"EVENT_TYPE": eventKey,
"SUB_ID": subscribeID,
},
Data: `{"eventKey":"user_im_message_reaction_group","subId":"sub-reaction-group","payload":{}}`,
})
h := bus.NewHub(10)
consumer, err := h.Register(transport.Hello{
EventTypes: []string{eventKey},
SubscribeID: subscribeID,
})
if err != nil {
t.Fatal(err)
}
h.Deliver(raw)
select {
case frame := <-consumer.SendCh:
eventFrame, ok := frame.(transport.Event)
if !ok {
t.Fatalf("frame = %T, want transport.Event", frame)
}
if eventFrame.EventType != eventKey || eventFrame.SubscribeID != subscribeID {
t.Fatalf("event = %#v", eventFrame)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for action event")
}
}
func TestPersonalSourceSenderEventPassesNormalBusFilter(t *testing.T) {
const (
eventKey = "user_im_message_receive_user"
subscribeID = "sub-receive-user"
)
src := personalSourceForRawEventTests()
raw := src.rawEventFromDataFrame(&payload.DataFrame{
Headers: payload.DataFrameHeader{
"EVENT_TYPE": eventKey,
"SUB_ID": subscribeID,
},
Data: `{"eventKey":"user_im_message_receive_user","subId":"sub-receive-user","payload":{}}`,
})
h := bus.NewHub(10)
consumer, err := h.Register(transport.Hello{
EventTypes: []string{eventKey},
SubscribeID: subscribeID,
})
if err != nil {
t.Fatal(err)
}
h.Deliver(raw)
select {
case frame := <-consumer.SendCh:
eventFrame, ok := frame.(transport.Event)
if !ok {
t.Fatalf("frame = %T, want transport.Event", frame)
}
if eventFrame.EventType != eventKey || eventFrame.SubscribeID != subscribeID {
t.Fatalf("event = %#v", eventFrame)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for sender event")
}
}
func personalSourceForRawEventTests() *PersonalSource {
return &PersonalSource{cfg: PersonalConfig{
SourceID: "fallback_source",
+5 -13
View File
@@ -10,7 +10,6 @@ import (
"io"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
@@ -189,10 +188,6 @@ func isNumericUserID(value string) bool {
return true
}
// dingTalkMediaIDPattern 匹配钉钉媒体 ID(上传接口返回),固定以 @ 开头,
// 后跟 base64 风格字符(如 @lADPfake、@lQDPM4DCjE1Mk_nNBInNBImw...)。
var dingTalkMediaIDPattern = regexp.MustCompile(`^@[A-Za-z0-9_\-+/=.]{4,}$`)
func isOpenDingTalkID(value string) bool {
value = strings.TrimSpace(value)
return len(value) > 0 && (value[0] == 'D' || value[0] == 'd')
@@ -1351,10 +1346,9 @@ func newChatCommand() *cobra.Command {
timeVal := mustGetFlag(cmd, "time")
if groupID != "" {
toolArgs := map[string]any{
"openCid": groupID,
"cid": groupID,
"time": timeVal,
"forward": forward,
"openconversation_id": groupID,
"time": timeVal,
"forward": forward,
}
if v := chatIntFlagOrFallback(cmd, "limit", "size"); v > 0 {
toolArgs["limit"] = v
@@ -3366,10 +3360,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
return err
}
iconMediaID := strings.TrimSpace(mustGetFlag(cmd, "icon-media-id"))
// 客户端预校验:钉钉媒体 ID 由上传接口(如 dt_media_upload)返回,
// 固定以 @ 开头(形如 @lADP.../@lQDP...)。明显非法的值本地直接报错。
if !dingTalkMediaIDPattern.MatchString(iconMediaID) {
return fmt.Errorf("invalid --icon-media-id %q: 钉钉媒体 ID 应以 @ 开头(如 @lADP...)\n hint: 先通过媒体上传命令(dt_media_upload)上传图片,使用返回的 mediaId", iconMediaID)
if iconMediaID == "" {
return fmt.Errorf("invalid --icon-media-id: mediaId 不能为空\n hint: 先通过媒体上传命令(dt_media_upload)上传图片,使用返回的 mediaId")
}
return callMCPToolOnServer("im", "update_group_icon", map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
@@ -43,6 +43,48 @@ func runChatCoverageDirect(t *testing.T, path []string, flags map[string]string)
return command.RunE(command, nil)
}
func TestCrossPlatformCoverageChatGroupUpdateIconAcceptsUploadedMediaIDPrefixes(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
for _, tc := range []struct {
name string
mediaID string
}{
{name: "at prefix", mediaID: "@lADPvalidMediaID"},
{name: "dollar prefix", mediaID: "$iAEvalidMediaID"},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &productExampleCaller{}
err := runChatCoverageCommand(t, caller,
"group", "update-icon", "--group=cid", "--icon-media-id="+tc.mediaID)
if err != nil {
t.Fatalf("update group icon with uploaded media ID %q: %v", tc.mediaID, err)
}
if caller.calls != 1 {
t.Fatalf("tool calls = %d, want 1", caller.calls)
}
})
}
}
func TestCrossPlatformCoverageChatGroupUpdateIconRejectsBlankMediaID(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
caller := &productExampleCaller{}
err := runChatCoverageCommand(t, caller,
"group", "update-icon", "--group=cid", "--icon-media-id= ")
if err == nil {
t.Fatal("update group icon with a blank media ID succeeded, want validation error")
}
if caller.calls != 0 {
t.Fatalf("tool calls = %d, want 0", caller.calls)
}
}
func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
+2 -2
View File
@@ -161,7 +161,7 @@ func executeChatChangedContract(t *testing.T, caller *chatChangedContractCaller,
return cmd.Execute()
}
func TestCrossPlatformCoverageChatMessageListUsesObservedGroupKeys(t *testing.T) {
func TestCrossPlatformCoverageChatMessageListUsesMCPMetadataGroupKey(t *testing.T) {
caller := &chatChangedContractCaller{}
err := executeChatChangedContract(t, caller,
"message", "list", "--group", "cid-1", "--time", "2026-07-15 09:00:00", "--limit", "50")
@@ -171,7 +171,7 @@ func TestCrossPlatformCoverageChatMessageListUsesObservedGroupKeys(t *testing.T)
if len(caller.calls) != 1 || caller.calls[0].toolName != "list_conversation_message_v2" {
t.Fatalf("calls = %#v", caller.calls)
}
want := map[string]any{"openCid": "cid-1", "cid": "cid-1", "time": "2026-07-15 09:00:00", "forward": true, "limit": 50}
want := map[string]any{"openconversation_id": "cid-1", "time": "2026-07-15 09:00:00", "forward": true, "limit": 50}
if !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("tool args = %#v, want %#v", caller.calls[0].args, want)
}
+8
View File
@@ -249,6 +249,14 @@ func callMCPToolInternalOpts(explicitServerID, toolName string, args map[string]
// DryRun 模式:仅预览工具名和参数,不实际调用 MCP Server
if deps.Caller.DryRun() {
if deps.Caller.Format() == "json" {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"executed": false,
"tool": toolName,
"arguments": args,
})
}
bold := color.New(color.FgYellow, color.Bold)
bold.Println("[DRY-RUN] Preview only, not executed:")
deps.Out.PrintKeyValue("Tool", toolName)
+26
View File
@@ -3,9 +3,11 @@ package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
@@ -176,6 +178,30 @@ func TestCrossPlatformCoverageMCPOutputModesAndDevdocFormatting(t *testing.T) {
}
}
func TestCrossPlatformCoverageMCPDryRunJSONOutputIsSingleDocument(t *testing.T) {
caller := &helpersCoreCaller{format: "json", dry: true}
out, _ := installHelpersCoreDeps(t, caller)
args := map[string]any{"name": "weekly", "enabled": true}
if err := callMCPToolInternalOpts("server", "create_workflow", args, false); err != nil {
t.Fatalf("dry run returned error: %v", err)
}
if caller.calls != 0 {
t.Fatalf("dry run called tool %d times, want 0", caller.calls)
}
var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("dry-run stdout must be one JSON document: %v\n%s", err, out.String())
}
if payload["dry_run"] != true || payload["executed"] != false || payload["tool"] != "create_workflow" {
t.Fatalf("unexpected dry-run payload: %#v", payload)
}
if !reflect.DeepEqual(payload["arguments"], map[string]any{"name": "weekly", "enabled": true}) {
t.Fatalf("arguments = %#v", payload["arguments"])
}
}
func TestCrossPlatformCoverageCurrentUserResponseShapes(t *testing.T) {
caller := &helpersCoreCaller{}
installHelpersCoreDeps(t, caller)
+65 -21
View File
@@ -202,8 +202,21 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
if err != nil {
return err
}
jsonMode := deps.Caller.Format() == "json"
if deps.Caller.DryRun() {
if jsonMode {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"operation": cfg.operation,
"file": file.path,
"name": file.name,
"format": file.extension,
"size": file.size,
})
}
deps.Out.PrintKeyValue("操作", cfg.operation)
deps.Out.PrintKeyValue("文件", file.path)
deps.Out.PrintKeyValue("名称", file.name)
@@ -217,7 +230,9 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
ctx = context.Background()
}
deps.Out.PrintInfo("[1/4] 创建导入会话...")
if !jsonMode {
deps.Out.PrintInfo("[1/4] 创建导入会话...")
}
sessionArgs := map[string]any{
"fileName": file.name,
"suffix": file.extension,
@@ -241,18 +256,28 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
sessionID, _ := sessionResult["sessionId"].(string)
uploadURL, _ := sessionResult["uploadUrl"].(string)
if sessionID == "" || uploadURL == "" {
deps.Out.PrintRaw(sessionText)
if !jsonMode {
deps.Out.PrintRaw(sessionText)
}
return fmt.Errorf("创建导入会话成功但缺少 sessionId 或 uploadUrl")
}
deps.Out.PrintInfo(fmt.Sprintf(" 会话已创建,sessionId: %s", sessionID))
if !jsonMode {
deps.Out.PrintInfo(fmt.Sprintf(" 会话已创建,sessionId: %s", sessionID))
}
deps.Out.PrintInfo("[2/4] 上传文件...")
if !jsonMode {
deps.Out.PrintInfo("[2/4] 上传文件...")
}
if err := httpPutFile(ctx, uploadURL, nil, file.path, file.size); err != nil {
return fmt.Errorf("文件上传失败 (sessionId=%s): %w", sessionID, err)
}
deps.Out.PrintInfo(" 文件上传完成")
if !jsonMode {
deps.Out.PrintInfo(" 文件上传完成")
}
deps.Out.PrintInfo("[3/4] 确认导入,启动格式转换...")
if !jsonMode {
deps.Out.PrintInfo("[3/4] 确认导入,启动格式转换...")
}
confirmText, err := cfg.callTool(ctx, "confirm_import", map[string]any{"sessionId": sessionID})
if err != nil {
return fmt.Errorf("确认导入失败 (sessionId=%s): %w", sessionID, err)
@@ -263,12 +288,18 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
}
taskID, _ := confirmResult["taskId"].(string)
if taskID == "" {
deps.Out.PrintRaw(confirmText)
if !jsonMode {
deps.Out.PrintRaw(confirmText)
}
return fmt.Errorf("确认导入成功但未返回 taskId")
}
deps.Out.PrintInfo(fmt.Sprintf(" 转换任务已提交,taskId: %s", taskID))
if !jsonMode {
deps.Out.PrintInfo(fmt.Sprintf(" 转换任务已提交,taskId: %s", taskID))
}
deps.Out.PrintInfo("[4/4] 等待格式转换完成...")
if !jsonMode {
deps.Out.PrintInfo("[4/4] 等待格式转换完成...")
}
result, err := pollImportTask(ctx, taskID, cfg)
if err != nil {
var timeoutErr *importPollTimeoutError
@@ -276,15 +307,16 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
return err
}
if cfg.timeoutAsResult {
deps.Out.PrintInfo(timeoutErr.Error())
_ = deps.Out.PrintJSON(map[string]any{
if !jsonMode {
deps.Out.PrintInfo(timeoutErr.Error())
}
return deps.Out.PrintJSON(map[string]any{
"success": false,
"timed_out": true,
"taskId": taskID,
"status": "processing",
"next_command": fmt.Sprintf(cfg.nextCommand, taskID),
})
return nil
}
return fmt.Errorf("%s,请稍后使用 %s 手动查询", timeoutErr.Error(), fmt.Sprintf(cfg.nextCommand, taskID))
}
@@ -302,9 +334,10 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
if cfg.includeNodeID {
finalResult["nodeId"] = extractNodeIDFromDocURL(documentURL)
}
deps.Out.PrintInfo(fmt.Sprintf("导入完成: %s", documentURL))
_ = deps.Out.PrintJSON(finalResult)
return nil
if !jsonMode {
deps.Out.PrintInfo(fmt.Sprintf("导入完成: %s", documentURL))
}
return deps.Out.PrintJSON(finalResult)
}
func runImportGetCommand(cmd *cobra.Command, cfg importFlowConfig) error {
@@ -313,6 +346,15 @@ func runImportGetCommand(cmd *cobra.Command, cfg importFlowConfig) error {
return fmt.Errorf("flag --task-id is required")
}
if deps.Caller.DryRun() {
if deps.Caller.Format() == "json" {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"operation": cfg.queryOperation,
"taskId": taskID,
})
}
deps.Out.PrintKeyValue("操作", cfg.queryOperation)
deps.Out.PrintKeyValue("任务ID", taskID)
return nil
@@ -339,15 +381,15 @@ func runImportGetCommand(cmd *cobra.Command, cfg importFlowConfig) error {
documentURL, _ := result["documentUrl"].(string)
result["nodeId"] = extractNodeIDFromDocURL(documentURL)
}
_ = deps.Out.PrintJSON(result)
return nil
return deps.Out.PrintJSON(result)
}
if strings.EqualFold(status, "processing") {
_ = deps.Out.PrintJSON(result)
return nil
return deps.Out.PrintJSON(result)
}
_ = deps.Out.PrintJSON(result)
if err := deps.Out.PrintJSON(result); err != nil {
return err
}
if message != "" {
return fmt.Errorf("导入任务失败 (status=%s): %s", status, message)
}
@@ -361,7 +403,9 @@ func pollImportTask(ctx context.Context, taskID string, cfg importFlowConfig) (m
}
for attempt := 1; attempt <= poll.maxPolls; attempt++ {
interval := poll.interval(attempt)
deps.Out.PrintInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, poll.maxPolls, interval))
if deps.Caller.Format() != "json" {
deps.Out.PrintInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, poll.maxPolls, interval))
}
if err := poll.wait(ctx, interval); err != nil {
return nil, fmt.Errorf("导入轮询被取消 (taskId=%s): %w", taskID, err)
}
+88 -19
View File
@@ -34,6 +34,12 @@ type sheetImportCall struct {
args map[string]any
}
type forcedJSONWriteFailure struct{}
func (forcedJSONWriteFailure) Write([]byte) (int, error) {
return 0, fmt.Errorf("forced JSON output failure")
}
type sheetImportCaller struct {
calls []sheetImportCall
responses map[string][]string
@@ -130,7 +136,7 @@ func TestSheetImportRejectsInvalidInputBeforeRemoteCall(t *testing.T) {
}
}
func TestSheetImportRunsSharedDocImportFlow(t *testing.T) {
func TestCrossPlatformCoverageSheetImportRunsSharedDocImportFlow(t *testing.T) {
caller := &sheetImportCaller{responses: map[string][]string{
"create_import_session": {`{"sessionId":"session-1","uploadUrl":"https://upload.example.test/object"}`},
"confirm_import": {`{"taskId":"task-1"}`},
@@ -171,12 +177,16 @@ func TestSheetImportRunsSharedDocImportFlow(t *testing.T) {
t.Fatalf("session args = %#v, want %#v", caller.calls[0].args, wantSession)
}
if !strings.Contains(output, `"nodeId": "node-1"`) || !strings.Contains(output, `"success": true`) {
t.Fatalf("output missing success contract:\n%s", output)
var payload map[string]any
if err := json.Unmarshal([]byte(output), &payload); err != nil {
t.Fatalf("sheet import stdout must be one JSON document: %v\n%s", err, output)
}
if payload["nodeId"] != "node-1" || payload["success"] != true {
t.Fatalf("output missing success contract: %#v", payload)
}
}
func TestSheetImportCreateLeafMatchesLegacyDryRun(t *testing.T) {
func TestCrossPlatformCoverageSheetImportCreateLeafMatchesLegacyDryRun(t *testing.T) {
filePath := writeImportFixture(t, "xlsx")
legacy, err := executeSheetImportCommand(t, &sheetImportCaller{dryRun: true}, fastSheetImportConfig(),
"--file", filePath, "--workspace", "workspace-1", "--name", "Sales")
@@ -191,6 +201,13 @@ func TestSheetImportCreateLeafMatchesLegacyDryRun(t *testing.T) {
if leaf != legacy {
t.Fatalf("create leaf output differs from legacy entry:\nlegacy:\n%s\nleaf:\n%s", legacy, leaf)
}
var payload map[string]any
if err := json.Unmarshal([]byte(legacy), &payload); err != nil {
t.Fatalf("sheet import dry-run stdout must be one JSON document: %v\n%s", err, legacy)
}
if payload["dry_run"] != true || payload["executed"] != false || payload["preview_kind"] != "plan" || payload["operation"] != "导入本地表格文件为在线电子表格" {
t.Fatalf("unexpected dry-run payload: %#v", payload)
}
root := newSheetImportCmdWithConfig(fastSheetImportConfig())
create, _, err := root.Find([]string{"create"})
@@ -202,7 +219,7 @@ func TestSheetImportCreateLeafMatchesLegacyDryRun(t *testing.T) {
}
}
func TestSheetImportTimeoutIsStructuredSuccessExit(t *testing.T) {
func TestCrossPlatformCoverageSheetImportTimeoutIsStructuredSuccessExit(t *testing.T) {
cfg := fastSheetImportConfig()
cfg.poll.maxPolls = 2
caller := &sheetImportCaller{responses: map[string][]string{
@@ -220,15 +237,12 @@ func TestSheetImportTimeoutIsStructuredSuccessExit(t *testing.T) {
if err != nil {
t.Fatalf("timeout should exit successfully, got %v", err)
}
for _, fragment := range []string{
`"success": false`,
`"timed_out": true`,
`"status": "processing"`,
`"next_command": "dws sheet import get --task-id task-1"`,
} {
if !strings.Contains(output, fragment) {
t.Fatalf("output missing %s:\n%s", fragment, output)
}
var payload map[string]any
if err := json.Unmarshal([]byte(output), &payload); err != nil {
t.Fatalf("timeout stdout must be one JSON document: %v\n%s", err, output)
}
if payload["success"] != false || payload["timed_out"] != true || payload["status"] != "processing" || payload["next_command"] != "dws sheet import get --task-id task-1" {
t.Fatalf("unexpected timeout payload: %#v", payload)
}
}
@@ -248,8 +262,7 @@ func TestSheetImportGetAddsNodeIDAndUsesDocServer(t *testing.T) {
}
var result map[string]any
jsonStart := strings.Index(output, "{")
if jsonStart < 0 || json.Unmarshal([]byte(output[jsonStart:]), &result) != nil {
if json.Unmarshal([]byte(output), &result) != nil {
t.Fatalf("invalid JSON output:\n%s", output)
}
if result["nodeId"] != "node-2" {
@@ -257,6 +270,58 @@ func TestSheetImportGetAddsNodeIDAndUsesDocServer(t *testing.T) {
}
}
func TestSheetImportGetPropagatesJSONWriteFailure(t *testing.T) {
tests := []struct {
name string
response string
}{
{name: "completed", response: `{"status":"completed","documentUrl":"https://alidocs.dingtalk.com/i/nodes/node-2/"}`},
{name: "processing", response: `{"status":"processing"}`},
{name: "failed", response: `{"status":"failed","message":"conversion failed"}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
caller := &sheetImportCaller{responses: map[string][]string{
"query_import_task": {tt.response},
}}
InitDeps(caller)
deps.Out.w = forcedJSONWriteFailure{}
root := newSheetImportCmdWithConfig(fastSheetImportConfig())
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs([]string{"get", "--task-id", "task-write-failure"})
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "forced JSON output failure") {
t.Fatalf("error = %v, want JSON write failure", err)
}
})
}
}
func TestCrossPlatformCoverageSheetImportGetDryRunJSONIsSingleDocument(t *testing.T) {
caller := &sheetImportCaller{dryRun: true}
output, err := executeSheetImportCommand(t, caller, fastSheetImportConfig(), "get", "--task-id", "task-dry-run")
if err != nil {
t.Fatalf("sheet import get dry-run returned error: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run remote calls = %#v, want none", caller.calls)
}
var payload map[string]any
if err := json.Unmarshal([]byte(output), &payload); err != nil {
t.Fatalf("sheet import get dry-run stdout must be one JSON document: %v\n%s", err, output)
}
if payload["dry_run"] != true || payload["executed"] != false || payload["preview_kind"] != "plan" || payload["taskId"] != "task-dry-run" || payload["operation"] != "查询表格导入任务结果" {
t.Fatalf("unexpected sheet import get dry-run payload: %#v", payload)
}
}
func TestDocImportConfigPreservesExistingContract(t *testing.T) {
cfg := docImportFlowConfig()
for _, ext := range []string{"docx", "doc", "xlsx", "xls", "md", "txt", "xmind", "mark"} {
@@ -269,7 +334,7 @@ func TestDocImportConfigPreservesExistingContract(t *testing.T) {
}
}
func TestDocImportDryRunStillAcceptsMarkdownWithoutTarget(t *testing.T) {
func TestCrossPlatformCoverageDocImportDryRunStillAcceptsMarkdownWithoutTarget(t *testing.T) {
previousDeps := deps
previousArgs := os.Args
t.Cleanup(func() {
@@ -293,8 +358,12 @@ func TestDocImportDryRunStillAcceptsMarkdownWithoutTarget(t *testing.T) {
if len(caller.calls) != 0 {
t.Fatalf("dry-run made %d remote calls", len(caller.calls))
}
if !strings.Contains(output.String(), "导入本地文件为在线文档") || !strings.Contains(output.String(), "md") {
t.Fatalf("unexpected dry-run output:\n%s", output.String())
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("doc import dry-run stdout must be one JSON document: %v\n%s", err, output.String())
}
if payload["operation"] != "导入本地文件为在线文档" || payload["format"] != "md" || payload["dry_run"] != true || payload["preview_kind"] != "plan" {
t.Fatalf("unexpected dry-run output: %#v", payload)
}
}
+9 -7
View File
@@ -462,11 +462,11 @@ func newRangeBatchSetStyleCmd() *cobra.Command {
}
fmt.Fprintf(os.Stderr, "[%d/%d] update_range sheet=%s range=%s\n", i+1, total, item.SheetID, item.Range)
if deps.Caller.DryRun() {
// The batch command normally uses callMCPToolReturnText in
// JSON mode, which intentionally performs a real call. Keep
// the dry-run guard before both output branches so neither
// format can bypass the shared no-network preview path.
_ = callMCPTool("update_range", toolArgs)
// JSON mode emits one aggregate preview below. Human formats
// reuse the shared per-call preview printer.
if !jsonMode {
_ = callMCPTool("update_range", toolArgs)
}
if jsonMode {
jsonResults = append(jsonResults, map[string]any{
"index": i + 1,
@@ -522,12 +522,14 @@ func newRangeBatchSetStyleCmd() *cobra.Command {
}
fmt.Fprintf(os.Stderr, "batch-set-style 完成:共 %d 条,失败 %d 条\n", total, failed)
if jsonMode {
_ = deps.Out.PrintJSON(map[string]any{
if err := deps.Out.PrintJSON(map[string]any{
"total": total,
"failed": failed,
"results": jsonResults,
"success": failed == 0,
})
}); err != nil {
return err
}
}
if failed > 0 && !continueOnErr {
return firstErr
+44 -7
View File
@@ -7,6 +7,7 @@ package helpers
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
@@ -30,7 +31,7 @@ func (*sheetStyleDryRunCaller) DryRun() bool { return true }
func (*sheetStyleDryRunCaller) Fields() string { return "" }
func (*sheetStyleDryRunCaller) JQ() string { return "" }
func TestRangeBatchSetStyleDryRunNeverCallsRemote(t *testing.T) {
func TestCrossPlatformCoverageRangeBatchSetStyleDryRunNeverCallsRemote(t *testing.T) {
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
@@ -56,14 +57,50 @@ func TestRangeBatchSetStyleDryRunNeverCallsRemote(t *testing.T) {
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
}
preview := output.String()
for _, want := range []string{"Tool:", "update_range", "Arguments:"} {
if !strings.Contains(preview, want) {
t.Fatalf("dry-run preview missing %q:\n%s", want, preview)
if format == "json" {
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("JSON dry-run stdout must be one document: %v\n%s", err, preview)
}
results, _ := payload["results"].([]any)
if len(results) != 1 {
t.Fatalf("JSON dry-run results = %#v", payload["results"])
}
entry, _ := results[0].(map[string]any)
if entry["dryRun"] != true || entry["tool"] != "update_range" {
t.Fatalf("JSON dry-run result = %#v", entry)
}
} else {
for _, want := range []string{"Tool:", "update_range", "Arguments:"} {
if !strings.Contains(preview, want) {
t.Fatalf("dry-run preview missing %q:\n%s", want, preview)
}
}
}
if format == "json" && !strings.Contains(preview, `"dryRun": true`) {
t.Fatalf("JSON dry-run summary missing typed dryRun evidence:\n%s", preview)
}
})
}
}
func TestRangeBatchSetStylePropagatesJSONWriteFailure(t *testing.T) {
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
batchPath := filepath.Join(t.TempDir(), "styles.json")
if err := os.WriteFile(batchPath, []byte(`[{"sheetId":"Sheet1","range":"A1:B2","fontWeight":"bold"}]`), 0o600); err != nil {
t.Fatalf("write batch fixture: %v", err)
}
caller := &sheetStyleDryRunCaller{format: "json"}
InitDeps(caller)
deps.Out.w = forcedJSONWriteFailure{}
cmd := newRangeBatchSetStyleCmd()
cmd.SetArgs([]string{"--node", "NODE_ID", "--batch", batchPath})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "forced JSON output failure") {
t.Fatalf("error = %v, want JSON write failure", err)
}
if caller.calls != 0 {
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
}
}
+3 -4
View File
@@ -221,10 +221,9 @@ var MessagesList = shortcut.Shortcut{
Execute: func(rt *shortcut.RuntimeContext) error {
group := rt.StrFirst("group", "conversation-id", "id")
params := map[string]any{
"openCid": group,
"cid": group,
"time": rt.Str("time"),
"forward": rt.Bool("forward"),
"openconversation_id": group,
"time": rt.Str("time"),
"forward": rt.Bool("forward"),
}
if limit := rt.IntFirst("limit", "size"); limit > 0 {
params["limit"] = limit
@@ -58,6 +58,7 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
wantProduct string
wantTool string
wantArgs map[string]any
wantAbsent []string
}{
{
name: "chat search keyword and size",
@@ -81,7 +82,8 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
},
wantProduct: "chat",
wantTool: "list_conversation_message_v2",
wantArgs: map[string]any{"openCid": "cid-1", "limit": 7},
wantArgs: map[string]any{"openconversation_id": "cid-1", "limit": 7},
wantAbsent: []string{"openCid", "cid"},
},
{
name: "direct messages size",
@@ -119,6 +121,11 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
t.Errorf("%s = %#v, want %#v", key, got, want)
}
}
for _, key := range tc.wantAbsent {
if _, ok := fake.args[key]; ok {
t.Errorf("unexpected legacy argument %q in %#v", key, fake.args)
}
}
})
}
}
+3 -4
View File
@@ -25,7 +25,7 @@ import (
//
// Steps:
// 1. depending on whether --group or --user is given, call either
// list_conversation_message_v2 (group; param openCid) or
// list_conversation_message_v2 (group; param openconversation_id) or
// list_individual_chat_message (single chat; param userId) on the chat
// server — tool names and param keys copied verbatim from chat.go's
// `dws chat message list` call sites;
@@ -70,7 +70,7 @@ var ChatMessages = shortcut.Shortcut{
},
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — build params and pick the right tool. Param keys
// (openCid/cid / userId / time / forward / limit) match the MCP server
// (openconversation_id / userId / time / forward / limit) match the MCP server
// schema for group and direct message listing.
var tool string
params := map[string]any{}
@@ -95,8 +95,7 @@ var ChatMessages = shortcut.Shortcut{
if group := rt.StrFirst("group", "conversation-id", "id"); group != "" {
tool = "list_conversation_message_v2"
params["openCid"] = group
params["cid"] = group
params["openconversation_id"] = group
} else {
tool = "list_individual_chat_message"
params["userId"] = rt.Str("user")
@@ -112,16 +112,18 @@ func TestCrossPlatformCoverageAIMessageTag(t *testing.T) {
func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
tests := []struct {
name string
argv []string
wantTool string
wantArgs map[string]any
name string
argv []string
wantTool string
wantArgs map[string]any
wantAbsent []string
}{
{
name: "chat messages id and size",
argv: []string{"chat", "+chat-messages", "--id", "cid-1", "--size", "9", "--yes"},
wantTool: "list_conversation_message_v2",
wantArgs: map[string]any{"openCid": "cid-1", "limit": 9},
name: "chat messages id and size",
argv: []string{"chat", "+chat-messages", "--id", "cid-1", "--size", "9", "--yes"},
wantTool: "list_conversation_message_v2",
wantArgs: map[string]any{"openconversation_id": "cid-1", "limit": 9},
wantAbsent: []string{"openCid", "cid"},
},
{
name: "search message id and keyword",
@@ -149,6 +151,11 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
t.Errorf("%s = %#v, want %#v", key, got, want)
}
}
for _, key := range tc.wantAbsent {
if _, ok := call.args[key]; ok {
t.Errorf("unexpected legacy argument %q in %#v", key, call.args)
}
}
})
}
}
+87
View File
@@ -0,0 +1,87 @@
#!/bin/sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
TAG="${1:-}"
DEST_DIR="${2:-}"
REPOSITORY="${GITHUB_REPOSITORY:-}"
RELEASE_ID="${DWS_GITHUB_RELEASE_ID:-}"
[ -n "$TAG" ] && [ -n "$DEST_DIR" ] && [ -n "$REPOSITORY" ] || {
printf 'usage: GITHUB_REPOSITORY=owner/repo [DWS_GITHUB_RELEASE_ID=id] download-github-release-assets.sh <tag> <destination>\n' >&2
exit 2
}
[ -n "$RELEASE_ID" ] || {
printf 'DWS_GITHUB_RELEASE_ID is required for an exact release download\n' >&2
exit 2
}
printf '%s\n' "$RELEASE_ID" | grep -Eq '^[1-9][0-9]*$' || {
printf 'invalid GitHub Release ID: %s\n' "$RELEASE_ID" >&2
exit 2
}
command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; }
DWS_GITHUB_RELEASE_ID="$RELEASE_ID" \
"$SCRIPT_DIR/verify-github-release-assets.sh" "$TAG"
tmp="$(mktemp -d "${TMPDIR:-/tmp}/dws-github-release-download.XXXXXX")"
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
tab="$(printf '\t')"
cat > "$tmp/expected" <<'EOF'
checksums.txt
dws-darwin-amd64.tar.gz
dws-darwin-arm64.tar.gz
dws-linux-amd64.tar.gz
dws-linux-arm64.tar.gz
dws-skills.zip
dws-windows-amd64.zip
dws-windows-arm64.zip
EOF
LC_ALL=C sort "$tmp/expected" -o "$tmp/expected"
gh api -H 'Accept: application/vnd.github+json' \
"repos/$REPOSITORY/releases/$RELEASE_ID" \
--jq '.assets[] | [.id, .name] | @tsv' > "$tmp/assets.tsv"
cut -f 2 "$tmp/assets.tsv" | LC_ALL=C sort > "$tmp/actual"
if ! diff -u "$tmp/expected" "$tmp/actual"; then
printf 'GitHub Release %s must contain exactly the supported assets\n' "$TAG" >&2
exit 1
fi
while IFS="$tab" read -r asset_id asset_name; do
printf '%s\n' "$asset_id" | grep -Eq '^[1-9][0-9]*$' || {
printf 'invalid GitHub Release asset ID: %s\n' "$asset_id" >&2
exit 1
}
case "$asset_name" in
checksums.txt | \
dws-darwin-amd64.tar.gz | dws-darwin-arm64.tar.gz | \
dws-linux-amd64.tar.gz | dws-linux-arm64.tar.gz | \
dws-windows-amd64.zip | dws-windows-arm64.zip | \
dws-skills.zip) ;;
*)
printf 'unsupported GitHub Release asset name: %s\n' "$asset_name" >&2
exit 1
;;
esac
gh api -H 'Accept: application/octet-stream' \
"repos/$REPOSITORY/releases/assets/$asset_id" > "$tmp/$asset_name"
done < "$tmp/assets.tsv"
mkdir -p "$DEST_DIR"
for asset_name in \
checksums.txt \
dws-darwin-amd64.tar.gz \
dws-darwin-arm64.tar.gz \
dws-linux-amd64.tar.gz \
dws-linux-arm64.tar.gz \
dws-windows-amd64.zip \
dws-windows-arm64.zip \
dws-skills.zip; do
[ -f "$tmp/$asset_name" ] || {
printf 'GitHub Release asset was not downloaded: %s\n' "$asset_name" >&2
exit 1
}
mv -f "$tmp/$asset_name" "$DEST_DIR/$asset_name"
done
printf 'GitHub Release assets downloaded and verified: %s\n' "$TAG"
+48
View File
@@ -16,6 +16,7 @@ usage() {
cat >&2 <<'EOF'
usage: dws-release [version] [options]
dws-release config [--remote <name>]
dws-release recover <version> [--failed-run <run-id>] [--failed-attempt <attempt>] [--remote <name>]
With no arguments, starts an interactive release guide. For a version that has
no CHANGELOG section yet, prepares the template and stops. Otherwise, runs the
@@ -34,6 +35,7 @@ Examples:
dws-release v1.2.3-beta.1 --publish
dws-release v1.2.3 --from-beta v1.2.3-beta.1
dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
dws-release recover v1.2.3-beta.1
EOF
}
@@ -191,9 +193,55 @@ configure_remote() {
exit 0
}
recover_release() {
shift
recovery_version="${1:-}"
[ -n "$recovery_version" ] || die_usage 'recover requires a release version'
case "$recovery_version" in -h|--help) usage; exit 0 ;; esac
shift
recovery_failed_run=""
recovery_failed_attempt=""
while [ "$#" -gt 0 ]; do
case "$1" in
--failed-run)
[ "$#" -ge 2 ] || die_usage '--failed-run requires a workflow run ID'
recovery_failed_run="$2"
shift 2
;;
--failed-attempt)
[ "$#" -ge 2 ] || die_usage '--failed-attempt requires a run attempt'
recovery_failed_attempt="$2"
shift 2
;;
--remote)
[ "$#" -ge 2 ] || die_usage '--remote requires a value'
REMOTE="$2"
shift 2
;;
-h|--help) usage; exit 0 ;;
*) die_usage "unknown recovery argument: $1" ;;
esac
done
cd "$ROOT"
require_remote
sync_main_if_safe
set -- "$recovery_version" --remote "$REMOTE"
if [ -n "$recovery_failed_run" ]; then
set -- "$@" --failed-run "$recovery_failed_run"
fi
if [ -n "$recovery_failed_attempt" ]; then
set -- "$@" --failed-attempt "$recovery_failed_attempt"
fi
exec "$SCRIPT_DIR/recover-release.sh" "$@"
}
if [ "${1:-}" = "config" ]; then
configure_remote "$@"
fi
if [ "${1:-}" = "recover" ]; then
recover_release "$@"
fi
[ "$#" -gt 0 ] || WIZARD=1
while [ "$#" -gt 0 ]; do
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
. "$SCRIPT_DIR/release-lib.sh"
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
VERSION="${1:-}"
REMOTE=""
FAILED_RUN_ID=""
FAILED_RUN_ATTEMPT=""
EXPECTED_REPOSITORY="DingTalk-Real-AI/dingtalk-workspace-cli"
usage() {
cat >&2 <<'EOF'
usage: recover-release.sh <version> --remote <name> [--failed-run <run-id>] [--failed-attempt <attempt>]
Recovers one failed existing release tag through the protected default-branch
Release workflow. It never creates, moves, or deletes a tag.
EOF
}
[ -n "$VERSION" ] || { usage; exit 2; }
shift
while [ "$#" -gt 0 ]; do
case "$1" in
--remote) [ "$#" -ge 2 ] || { usage; exit 2; }; REMOTE="$2"; shift 2 ;;
--failed-run) [ "$#" -ge 2 ] || { usage; exit 2; }; FAILED_RUN_ID="$2"; shift 2 ;;
--failed-attempt) [ "$#" -ge 2 ] || { usage; exit 2; }; FAILED_RUN_ATTEMPT="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) printf 'unknown recovery argument: %s\n' "$1" >&2; usage; exit 2 ;;
esac
done
channel="$(release_channel_for_version "$VERSION")" || exit 2
release_validate_version_channel "$channel" "$VERSION"
[ -n "$REMOTE" ] || { printf '%s\n' '--remote is required' >&2; exit 2; }
if [ -n "$FAILED_RUN_ID" ]; then
printf '%s\n' "$FAILED_RUN_ID" | grep -Eq '^[1-9][0-9]*$' || {
printf 'invalid failed Release run ID: %s\n' "$FAILED_RUN_ID" >&2
exit 2
}
fi
if [ -n "$FAILED_RUN_ATTEMPT" ]; then
[ -n "$FAILED_RUN_ID" ] || {
printf '%s\n' '--failed-attempt requires --failed-run' >&2
exit 2
}
printf '%s\n' "$FAILED_RUN_ATTEMPT" | grep -Eq '^[1-9][0-9]*$' || {
printf 'invalid failed Release run attempt: %s\n' "$FAILED_RUN_ATTEMPT" >&2
exit 2
}
fi
command -v gh >/dev/null 2>&1 || { printf '%s\n' 'gh is required for release recovery' >&2; exit 1; }
find_latest_failed_attempt() {
find_run_id="$1"
find_latest="$(
gh api \
-H 'Accept: application/vnd.github+json' \
"repos/$EXPECTED_REPOSITORY/actions/runs/$find_run_id" \
--jq .run_attempt
)" || return 1
printf '%s\n' "$find_latest" | grep -Eq '^[1-9][0-9]*$' || return 1
find_attempt="$find_latest"
while [ "$find_attempt" -ge 1 ]; do
find_conclusion="$(
gh api \
-H 'Accept: application/vnd.github+json' \
"repos/$EXPECTED_REPOSITORY/actions/runs/$find_run_id/attempts/$find_attempt" \
--jq .conclusion
)" || return 1
case "$find_conclusion" in
failure|cancelled|timed_out|startup_failure|stale)
printf '%s\n' "$find_attempt"
return 0
;;
esac
find_attempt=$((find_attempt - 1))
done
return 1
}
cd "$ROOT"
[ "$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)" = "main" ] || {
printf '%s\n' 'release recovery must run from the main worktree' >&2
exit 1
}
[ -z "$(git status --porcelain --untracked-files=all)" ] || {
printf '%s\n' 'release recovery requires a clean main worktree' >&2
exit 1
}
push_url="$(git remote get-url --push "$REMOTE" 2>/dev/null)" || {
printf 'unknown release remote: %s\n' "$REMOTE" >&2
exit 1
}
case "$push_url" in
https://github.com/*) repository_path="${push_url#https://github.com/}" ;;
git@github.com:*) repository_path="${push_url#git@github.com:}" ;;
ssh://git@github.com/*) repository_path="${push_url#ssh://git@github.com/}" ;;
*) printf 'release recovery requires the official GitHub remote, got: %s\n' "$push_url" >&2; exit 1 ;;
esac
repository_path="${repository_path%/}"
repository_path="${repository_path%.git}"
[ "$repository_path" = "$EXPECTED_REPOSITORY" ] || {
printf 'release recovery is restricted to %s, got %s\n' "$EXPECTED_REPOSITORY" "$repository_path" >&2
exit 1
}
printf '==> Refreshing main and exact recovery tag %s\n' "$VERSION"
git fetch --force "$REMOTE" "+refs/heads/main:refs/remotes/$REMOTE/main"
recovery_ref="refs/dws-release-recovery/$VERSION"
cleanup() { git update-ref -d "$recovery_ref" >/dev/null 2>&1 || true; }
trap cleanup EXIT HUP INT TERM
git fetch --force --no-tags "$REMOTE" "+refs/tags/$VERSION:$recovery_ref"
[ "$(git cat-file -t "$recovery_ref")" = "tag" ] || {
printf '%s must be an annotated tag\n' "$VERSION" >&2
exit 1
}
tag_object="$(git rev-parse "$recovery_ref")"
commit="$(git rev-parse "$recovery_ref^{commit}")"
git merge-base --is-ancestor "$commit" "refs/remotes/$REMOTE/main" || {
printf '%s commit %s is not contained in %s/main\n' "$VERSION" "$commit" "$REMOTE" >&2
exit 1
}
if [ -z "$FAILED_RUN_ID" ]; then
candidate_runs="$(
gh api \
-H 'Accept: application/vnd.github+json' \
"repos/$EXPECTED_REPOSITORY/actions/workflows/release.yml/runs?branch=$VERSION&event=push&status=completed&per_page=100" \
--jq ".workflow_runs[] | select(.head_sha == \"$commit\" and .head_branch == \"$VERSION\") | .id"
)" || {
printf 'could not query Release runs for %s\n' "$VERSION" >&2
exit 1
}
for candidate_run_id in $candidate_runs; do
if candidate_attempt="$(find_latest_failed_attempt "$candidate_run_id")"; then
FAILED_RUN_ID="$candidate_run_id"
FAILED_RUN_ATTEMPT="$candidate_attempt"
break
fi
done
[ -n "$FAILED_RUN_ID" ] || {
printf 'no failed exact-tag Release run found for %s at %s\n' "$VERSION" "$commit" >&2
exit 1
}
elif [ -z "$FAILED_RUN_ATTEMPT" ]; then
FAILED_RUN_ATTEMPT="$(find_latest_failed_attempt "$FAILED_RUN_ID")" || {
printf 'Release run %s has no failed attempt\n' "$FAILED_RUN_ID" >&2
exit 1
}
fi
printf '%s\n' "$FAILED_RUN_ATTEMPT" | grep -Eq '^[1-9][0-9]*$' || {
printf 'invalid failed Release run attempt: %s\n' "$FAILED_RUN_ATTEMPT" >&2
exit 1
}
attempt_record="$(
gh api \
-H 'Accept: application/vnd.github+json' \
"repos/$EXPECTED_REPOSITORY/actions/runs/$FAILED_RUN_ID/attempts/$FAILED_RUN_ATTEMPT" \
--jq '[.id, .run_attempt, .repository.full_name, .path, .event, .status, .conclusion, .head_branch, .head_sha] | @tsv'
)" || {
printf 'could not query Release run %s attempt %s\n' "$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT" >&2
exit 1
}
attempt_id="$(printf '%s\n' "$attempt_record" | cut -f1)"
attempt_number="$(printf '%s\n' "$attempt_record" | cut -f2)"
attempt_repository="$(printf '%s\n' "$attempt_record" | cut -f3)"
attempt_path="$(printf '%s\n' "$attempt_record" | cut -f4)"
attempt_event="$(printf '%s\n' "$attempt_record" | cut -f5)"
attempt_status="$(printf '%s\n' "$attempt_record" | cut -f6)"
attempt_conclusion="$(printf '%s\n' "$attempt_record" | cut -f7)"
attempt_branch="$(printf '%s\n' "$attempt_record" | cut -f8)"
attempt_commit="$(printf '%s\n' "$attempt_record" | cut -f9)"
case "$attempt_conclusion" in
failure|cancelled|timed_out|startup_failure|stale) ;;
*)
printf 'Release run %s attempt %s is not failed: %s\n' \
"$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT" "$attempt_conclusion" >&2
exit 1
;;
esac
if [ "$attempt_id" != "$FAILED_RUN_ID" ] ||
[ "$attempt_number" != "$FAILED_RUN_ATTEMPT" ] ||
[ "$attempt_repository" != "$EXPECTED_REPOSITORY" ] ||
[ "$attempt_path" != ".github/workflows/release.yml" ] ||
[ "$attempt_event" != "push" ] ||
[ "$attempt_status" != "completed" ] ||
[ "$attempt_branch" != "$VERSION" ] ||
[ "$attempt_commit" != "$commit" ]; then
printf 'Release run %s attempt %s does not match %s at %s\n' \
"$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT" "$VERSION" "$commit" >&2
exit 1
fi
printf 'Recovery target:\n'
printf ' version: %s\n' "$VERSION"
printf ' tag object: %s\n' "$tag_object"
printf ' commit: %s\n' "$commit"
printf ' failed run: https://github.com/%s/actions/runs/%s/attempts/%s\n' \
"$EXPECTED_REPOSITORY" "$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT"
[ -t 0 ] || { printf '%s\n' 'interactive recovery confirmation is required' >&2; exit 1; }
printf 'Type %s to request protected recovery: ' "$VERSION"
IFS= read -r confirmation
[ "$confirmation" = "$VERSION" ] || { printf '%s\n' 'release recovery cancelled' >&2; exit 1; }
nonce="${commit}-$(date +%s)-$$"
workflow_sha="$(git rev-parse "refs/remotes/$REMOTE/main")"
gh workflow run release.yml \
--repo "$EXPECTED_REPOSITORY" \
--ref main \
-f "recover_release_version=$VERSION" \
-f "recover_release_tag_object=$tag_object" \
-f "recover_release_commit=$commit" \
-f "recover_failed_run_id=$FAILED_RUN_ID" \
-f "recover_failed_run_attempt=$FAILED_RUN_ATTEMPT" \
-f "recover_release_nonce=$nonce" \
-f "recover_release_confirmation=$VERSION"
expected_title="Release recovery $VERSION at $commit $nonce"
started_at="$(date +%s)"
run_id=""
while :; do
run_id="$(
gh api \
-H 'Accept: application/vnd.github+json' \
"repos/$EXPECTED_REPOSITORY/actions/workflows/release.yml/runs?event=workflow_dispatch&branch=main&per_page=100" \
--jq ".workflow_runs[] | select(.display_title == \"$expected_title\" and .head_sha == \"$workflow_sha\") | .id" \
| sed -n '1p'
)" || exit 1
[ -z "$run_id" ] || break
now="$(date +%s)"
[ $((now - started_at)) -lt 60 ] || {
printf 'recovery was dispatched but its run could not be located: %s\n' "$expected_title" >&2
exit 1
}
sleep 2
done
printf 'Protected recovery run: https://github.com/%s/actions/runs/%s\n' "$EXPECTED_REPOSITORY" "$run_id"
printf '%s\n' 'Approve the release-recovery environment when prompted; this command will follow the run.'
if ! gh run watch "$run_id" --repo "$EXPECTED_REPOSITORY" --exit-status; then
gh run view "$run_id" --repo "$EXPECTED_REPOSITORY" --log-failed >&2 || true
exit 1
fi
printf 'Release recovery completed: %s\n' "$VERSION"
+158 -68
View File
@@ -120,21 +120,6 @@ require_github_publication_authority() {
return 1
}
immutable_enabled="$(
gh api \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$github_repository/immutable-releases" \
--jq '.enabled'
)" || {
printf 'immutable releases are not enabled for %s; enable them before allocating a tag\n' "$github_repository" >&2
return 1
}
[ "$immutable_enabled" = "true" ] || {
printf 'immutable releases are not enabled for %s\n' "$github_repository" >&2
return 1
}
active_runs="$(
gh api -H 'Accept: application/vnd.github+json' \
"repos/$github_repository/actions/workflows/release.yml/runs?per_page=100" \
@@ -195,18 +180,82 @@ require_github_publication_authority() {
}
done
beta_commit="$(git rev-parse "$FROM_BETA^{commit}")"
beta_runs="$(
gh api -H 'Accept: application/vnd.github+json' \
"repos/$github_repository/actions/workflows/release.yml/runs?branch=$FROM_BETA&event=push&status=completed&per_page=100" \
--jq '.workflow_runs[] | [.head_sha, .head_branch, .conclusion] | @tsv'
)" || return 1
printf '%s\n' "$beta_runs" | awk -F '\t' -v sha="$beta_commit" -v tag="$FROM_BETA" '
$1 == sha && $2 == tag && $3 == "success" { found = 1 }
END { exit(found ? 0 : 1) }
' || {
printf 'Release workflow did not succeed for %s at %s\n' "$FROM_BETA" "$beta_commit" >&2
DWS_RELEASE_OFFICIAL_REPOSITORY="$github_repository" \
"$SCRIPT_DIR/verify-release-workflow-delivery.sh" "$FROM_BETA" "$beta_commit" || return 1
fi
}
require_release_governance_ci() {
governance_repository="$(github_repository_from_url "$push_url" 2>/dev/null || true)"
if [ -z "$governance_repository" ]; then
[ "${DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE:-0}" = "1" ] || {
printf 'release governance preflight requires a github.com publication remote\n' >&2
return 1
}
printf 'warning: Release governance CI preflight explicitly disabled for non-GitHub test remote\n' >&2
return 0
fi
"$SCRIPT_DIR/verify-release-governance-ci.sh" \
"$governance_repository" \
"$(git rev-parse HEAD)"
}
preflight_proof_path() {
proof_dir="$(git rev-parse --git-path dws-release-preflight)"
printf '%s/%s.proof\n' "$proof_dir" "$VERSION"
}
preflight_proof_is_reusable() {
proof_file="$1"
[ -f "$proof_file" ] || return 1
[ "$(wc -l < "$proof_file" | tr -d '[:space:]')" -eq 9 ] || return 1
[ "$(sed -n 's/^format=//p' "$proof_file")" = "1" ] || return 1
[ "$(sed -n 's/^channel=//p' "$proof_file")" = "$CHANNEL" ] || return 1
[ "$(sed -n 's/^version=//p' "$proof_file")" = "$VERSION" ] || return 1
[ "$(sed -n 's/^commit=//p' "$proof_file")" = "$(git rev-parse HEAD)" ] || return 1
[ "$(sed -n 's/^repository=//p' "$proof_file")" = "$push_identity" ] || return 1
[ "$(sed -n 's/^branch=//p' "$proof_file")" = "$BRANCH" ] || return 1
[ "$(sed -n 's/^from_beta=//p' "$proof_file")" = "$FROM_BETA" ] || return 1
[ "$(sed -n 's/^previous_stable=//p' "$proof_file")" = "$previous_stable" ] || return 1
proof_created_at="$(sed -n 's/^created_at=//p' "$proof_file")"
printf '%s\n' "$proof_created_at" | grep -Eq '^[0-9]+$' || return 1
proof_now="$(date +%s)"
[ "$proof_created_at" -le "$proof_now" ] || return 1
[ $((proof_now - proof_created_at)) -le 21600 ] || return 1
}
write_preflight_proof() {
proof_file="$1"
proof_dir="$(dirname "$proof_file")"
umask 077
mkdir -p "$proof_dir"
proof_tmp="${proof_file}.tmp.$$"
{
printf 'format=1\n'
printf 'channel=%s\n' "$CHANNEL"
printf 'version=%s\n' "$VERSION"
printf 'commit=%s\n' "$(git rev-parse HEAD)"
printf 'repository=%s\n' "$push_identity"
printf 'branch=%s\n' "$BRANCH"
printf 'from_beta=%s\n' "$FROM_BETA"
printf 'previous_stable=%s\n' "$previous_stable"
printf 'created_at=%s\n' "$(date +%s)"
} > "$proof_tmp"
mv "$proof_tmp" "$proof_file"
}
build_policy_binary() {
policy_tmp="$ROOT/tmp/go-build"
mkdir -p "$policy_tmp"
GOTMPDIR="$policy_tmp" make build
if [ "$(uname -s)" = "Darwin" ] && [ "${DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE:-0}" != "1" ]; then
command -v codesign >/dev/null 2>&1 || {
printf '%s\n' 'codesign is required to prepare the macOS policy binary' >&2
return 1
}
codesign --force --sign - "$ROOT/dws"
codesign --verify --strict "$ROOT/dws"
fi
}
@@ -251,37 +300,45 @@ previous_stable="$(sed -n 's/^previous_stable=//p' "$metadata" | tail -1)"
printf '==> Verifying delivered stable baseline %s\n' "${previous_stable:-none}"
require_delivered_previous_stable
if [ "$PUBLISH" -eq 1 ]; then
printf '==> Verifying GitHub publication authority before local gates\n'
require_github_publication_authority
printf '==> Verifying GitHub publication authority before local gates\n'
require_github_publication_authority
proof_path="$(preflight_proof_path)"
ran_expensive_gates=0
if [ "$PUBLISH" -eq 1 ] && preflight_proof_is_reusable "$proof_path"; then
printf '==> Reusing exact preflight proof for %s at %s\n' "$VERSION" "$(git rev-parse HEAD)"
else
rm -f "$proof_path"
printf '==> Running repository test, build, and policy gates\n'
make test
# Installer tests intentionally exercise source builds. Rebuild the release
# binary before policy so policy never depends on a pre-existing ./dws.
build_policy_binary
make policy
if [ -n "$previous_stable" ]; then
printf '==> Comparing command tree with %s\n' "$previous_stable"
"$ROOT/scripts/policy/check-command-compatibility.sh" \
--base-ref "$REMOTE/$BRANCH" \
--stable-ref "$previous_stable"
fi
printf '==> Building local release artifacts for %s\n' "$VERSION"
make package VERSION="$VERSION"
printf '==> Verifying release artifact set and checksums\n'
"$SCRIPT_DIR/verify-release-artifacts.sh" "$VERSION"
printf '==> Verifying npm package installation\n'
"$SCRIPT_DIR/verify-package-managers.sh" --npm-only --expected-version "$VERSION"
if command -v brew >/dev/null 2>&1; then
printf '==> Verifying Homebrew package installation\n'
"$SCRIPT_DIR/verify-package-managers.sh" --brew-only --expected-version "$VERSION"
fi
ran_expensive_gates=1
fi
printf '==> Running repository test and policy gates\n'
make test
make policy
if [ -n "$previous_stable" ]; then
printf '==> Comparing command tree with %s\n' "$previous_stable"
"$ROOT/scripts/policy/check-command-compatibility.sh" \
--base-ref "$REMOTE/$BRANCH" \
--stable-ref "$previous_stable"
fi
printf '==> Building local release artifacts for %s\n' "$VERSION"
make package VERSION="$VERSION"
printf '==> Verifying release artifact set and checksums\n'
"$SCRIPT_DIR/verify-release-artifacts.sh" "$VERSION"
printf '==> Verifying npm package installation\n'
"$SCRIPT_DIR/verify-package-managers.sh" --npm-only --expected-version "$VERSION"
if command -v brew >/dev/null 2>&1; then
printf '==> Verifying Homebrew package installation\n'
"$SCRIPT_DIR/verify-package-managers.sh" --brew-only --expected-version "$VERSION"
fi
# Collect human confirmation before the final authority refresh. Nothing may
# block between that refresh and tag creation.
# Collect human confirmation before the final remote validations.
if [ "$PUBLISH" -eq 1 ] && [ "$YES" -ne 1 ]; then
if [ ! -t 0 ]; then
printf 'interactive confirmation is unavailable; pass --yes after reviewing the preflight\n' >&2
@@ -292,34 +349,65 @@ if [ "$PUBLISH" -eq 1 ] && [ "$YES" -ne 1 ]; then
[ "$confirmation" = "$VERSION" ] || { printf 'release cancelled\n' >&2; exit 1; }
fi
# Re-check after every local gate so tag creation cannot race with a modified
# source tree. dist/ is ignored and therefore does not make the tree dirty.
printf '==> Verifying the Release workflow publication identity\n'
require_release_governance_ci
# Refresh after every local gate and the remote governance check. dist/ is
# ignored and therefore does not make the tree dirty.
printf '==> Refreshing %s/%s before sealing the tag\n' "$REMOTE" "$BRANCH"
git fetch --force "$REMOTE" "+refs/heads/$BRANCH:refs/remotes/$REMOTE/$BRANCH"
fetch_release_tags
run_contract --metadata-output "$final_metadata"
final_previous_stable="$(sed -n 's/^previous_stable=//p' "$final_metadata" | tail -1)"
if [ "$final_previous_stable" != "$previous_stable" ]; then
[ -n "$final_previous_stable" ] || {
printf 'latest stable authority unexpectedly became empty\n' >&2
exit 1
}
previous_stable_before_refresh="$previous_stable"
previous_stable="$final_previous_stable"
printf '==> Revalidating delivered stable baseline %s\n' "$previous_stable"
require_delivered_previous_stable
if [ "$previous_stable" != "$previous_stable_before_refresh" ]; then
printf '==> Stable authority advanced from %s to %s; rechecking command compatibility\n' \
"${previous_stable:-none}" "${final_previous_stable:-none}"
[ -n "$final_previous_stable" ] || {
printf 'latest stable authority unexpectedly became empty\n' >&2
exit 1
}
"${previous_stable_before_refresh:-none}" "$previous_stable"
"$ROOT/scripts/policy/check-command-compatibility.sh" \
--base-ref "$REMOTE/$BRANCH" \
--stable-ref "$final_previous_stable"
--stable-ref "$previous_stable"
fi
if [ "$PUBLISH" -eq 1 ]; then
printf '==> Reconfirming GitHub publication authority on the settled baseline\n'
require_github_publication_authority
fi
# Delivery, compatibility, and publication checks above may take long enough
# for main or stable authority to move. This last refresh must be followed only
# by local proof/tag creation. The atomic push advertises main with the tag, so
# an already-advanced remote main rejects the whole transaction; a later main
# advance is safe because the sealed commit remains in protected main history.
printf '==> Settling final %s/%s and stable authority\n' "$REMOTE" "$BRANCH"
git fetch --force "$REMOTE" "+refs/heads/$BRANCH:refs/remotes/$REMOTE/$BRANCH"
fetch_release_tags
run_contract --metadata-output "$final_metadata"
settled_previous_stable="$(sed -n 's/^previous_stable=//p' "$final_metadata" | tail -1)"
[ "$settled_previous_stable" = "$previous_stable" ] || {
printf 'stable authority moved from %s to %s during final validation; rerun the release command\n' \
"$previous_stable" "$settled_previous_stable" >&2
exit 1
}
if [ "$ran_expensive_gates" -eq 1 ]; then
write_preflight_proof "$proof_path"
fi
if [ "$PUBLISH" -ne 1 ]; then
printf '\nPreflight passed. No tag was created.\n'
printf 'Publish with the same command plus --publish.\n'
printf 'Publish within six hours with the same command plus --publish to reuse this exact proof.\n'
exit 0
fi
printf '==> Reconfirming GitHub publication authority immediately before tag creation\n'
require_github_publication_authority
printf '==> Creating annotated tag %s\n' "$VERSION"
if [ "$CHANNEL" = "stable" ]; then
git tag -a "$VERSION" -m "Release $VERSION" -m 'Channel: stable' -m "From-Beta: $FROM_BETA"
@@ -327,7 +415,8 @@ else
git tag -a "$VERSION" -m "Release $VERSION" -m 'Channel: prerelease'
fi
if ! git push "$push_url" "refs/tags/$VERSION"; then
if ! git push --atomic "$push_url" \
"HEAD:refs/heads/$BRANCH" "refs/tags/$VERSION"; then
set +e
remote_refs="$(git ls-remote --tags "$push_url" "refs/tags/$VERSION" "refs/tags/$VERSION^{}")"
query_status=$?
@@ -351,3 +440,4 @@ if ! git push "$push_url" "refs/tags/$VERSION"; then
fi
printf 'Release tag pushed: %s -> %s. CI/CD now owns artifact publication.\n' "$VERSION" "$push_url"
rm -f "$proof_path"
@@ -79,6 +79,14 @@ printf '%s\n' "$stable_runs" | awk -F '\t' -v sha="$EXPECTED_COMMIT" -v tag="$TA
' && delivered_by_push=1 || delivered_by_push=0
if [ "$delivered_by_push" -ne 1 ]; then
if DWS_RELEASE_OFFICIAL_REPOSITORY="$REPOSITORY" \
DWS_RELEASE_GITHUB_TOKEN="$API_TOKEN" \
"$SCRIPT_DIR/verify-release-workflow-delivery.sh" "$TAG" "$EXPECTED_COMMIT" 2>/dev/null; then
printf 'Delivered stable baseline verified through protected default-branch recovery: %s -> %s\n' \
"$TAG" "$EXPECTED_COMMIT"
exit 0
fi
recovery_proof="$(
python3 - "$RECOVERY_MANIFEST" "$TAG" "$EXPECTED_COMMIT" <<'PY'
import json
@@ -3,13 +3,32 @@ set -eu
TAG="${1:-}"
REPOSITORY="${GITHUB_REPOSITORY:-}"
RELEASE_ID="${DWS_GITHUB_RELEASE_ID:-}"
[ -n "$TAG" ] && [ -n "$REPOSITORY" ] || {
printf 'usage: GITHUB_REPOSITORY=owner/repo verify-github-release-assets.sh <tag>\n' >&2
printf 'usage: GITHUB_REPOSITORY=owner/repo [DWS_GITHUB_RELEASE_ID=id] verify-github-release-assets.sh <tag>\n' >&2
exit 2
}
command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; }
if [ -z "$RELEASE_ID" ]; then
RELEASE_ID="$(
gh release view "$TAG" \
--repo "$REPOSITORY" \
--json databaseId,isDraft \
--jq 'select(.isDraft == true) | .databaseId' 2>/dev/null || true
)"
fi
if [ -n "$RELEASE_ID" ]; then
printf '%s\n' "$RELEASE_ID" | grep -Eq '^[1-9][0-9]*$' || {
printf 'invalid GitHub Release ID: %s\n' "$RELEASE_ID" >&2
exit 2
}
release_endpoint="repos/$REPOSITORY/releases/$RELEASE_ID"
else
release_endpoint="repos/$REPOSITORY/releases/tags/$TAG"
fi
tmp="$(mktemp -d "${TMPDIR:-/tmp}/dws-github-assets.XXXXXX")"
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
cat > "$tmp/expected" <<'EOF'
@@ -24,8 +43,17 @@ dws-windows-arm64.zip
EOF
LC_ALL=C sort "$tmp/expected" -o "$tmp/expected"
resolved_tag="$(
gh api -H 'Accept: application/vnd.github+json' \
"$release_endpoint" \
--jq '.tag_name'
)"
[ "$resolved_tag" = "$TAG" ] || {
printf 'GitHub Release ID/tag mismatch: expected %s, got %s\n' "$TAG" "$resolved_tag" >&2
exit 1
}
gh api -H 'Accept: application/vnd.github+json' \
"repos/$REPOSITORY/releases/tags/$TAG" \
"$release_endpoint" \
--jq '.assets[].name' | LC_ALL=C sort > "$tmp/actual"
if ! diff -u "$tmp/expected" "$tmp/actual"; then
printf 'GitHub Release %s must contain exactly the supported assets\n' "$TAG" >&2
@@ -3,15 +3,21 @@ set -eu
TAG="${1:-}"
EXPECTED_COMMIT="${2:-}"
EXPECTED_TAG_OBJECT="${3:-}"
REPOSITORY="${GITHUB_REPOSITORY:-}"
[ -n "$TAG" ] && [ -n "$EXPECTED_COMMIT" ] && [ -n "$REPOSITORY" ] || {
printf 'usage: GITHUB_REPOSITORY=owner/repo verify-github-tag-authority.sh <tag> <commit>\n' >&2
printf 'usage: GITHUB_REPOSITORY=owner/repo verify-github-tag-authority.sh <tag> <commit> [tag-object]\n' >&2
exit 2
}
command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; }
local_object="$(git rev-parse "refs/tags/$TAG")"
if [ -n "$EXPECTED_TAG_OBJECT" ] && [ "$local_object" != "$EXPECTED_TAG_OBJECT" ]; then
printf 'local tag object for %s changed (expected=%s actual=%s)\n' \
"$TAG" "$EXPECTED_TAG_OBJECT" "$local_object" >&2
exit 1
fi
remote_ref="$(
gh api -H 'Accept: application/vnd.github+json' \
"repos/$REPOSITORY/git/ref/tags/$TAG" \
+254
View File
@@ -0,0 +1,254 @@
#!/bin/sh
set -eu
REPOSITORY="${1:-}"
MODE="${2:-}"
TOKEN="${HOMEBREW_PR_TOKEN:-}"
usage() {
printf '%s\n' 'usage: verify-homebrew-pr-token.sh <owner/repository> [--canary]' >&2
}
err() {
printf 'error: %s\n' "$*" >&2
exit 1
}
[ -n "$REPOSITORY" ] || {
usage
exit 2
}
case "$REPOSITORY" in
*/*) ;;
*) usage; exit 2 ;;
esac
case "$MODE" in
""|--canary) ;;
*) usage; exit 2 ;;
esac
[ -n "$TOKEN" ] || err "HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases"
command -v gh >/dev/null 2>&1 || err "gh is required to verify HOMEBREW_PR_TOKEN"
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-homebrew-token-XXXXXX")"
CANARY_ROOT=""
CANARY_BRANCH=""
CANARY_COMMIT=""
CANARY_BRANCH_MAY_EXIST="false"
CANARY_PR_MAY_EXIST="false"
CANARY_PR_NUMBER=""
cleanup() {
status=$?
trap - EXIT INT TERM
set +e
cleanup_failed="false"
if [ "$CANARY_PR_MAY_EXIST" = "true" ]; then
cleanup_pr_number="$CANARY_PR_NUMBER"
if [ -z "$cleanup_pr_number" ]; then
if ! cleanup_pr_number="$(find_canary_pr_number)"; then
printf 'error: could not inspect Homebrew token canary PR for branch %s\n' \
"$CANARY_BRANCH" >&2
cleanup_failed="true"
fi
fi
if [ -n "$cleanup_pr_number" ] &&
! close_canary_pr "$cleanup_pr_number"; then
printf 'error: could not clean up Homebrew token canary PR #%s\n' \
"$cleanup_pr_number" >&2
cleanup_failed="true"
fi
fi
if [ "$CANARY_BRANCH_MAY_EXIST" = "true" ]; then
if ! delete_canary_branch >/dev/null 2>&1; then
printf 'error: could not clean up Homebrew token canary branch %s\n' \
"$CANARY_BRANCH" >&2
cleanup_failed="true"
fi
fi
if ! rm -rf "$TMP_ROOT"; then
printf 'error: could not clean up Homebrew token canary workspace\n' >&2
cleanup_failed="true"
fi
if [ "$cleanup_failed" = "true" ] && [ "$status" -eq 0 ]; then
status=1
fi
exit "$status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
response="$TMP_ROOT/repository-response"
if ! GH_TOKEN="$TOKEN" gh api \
-i \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$REPOSITORY" \
--jq '[.full_name, (.permissions.push // false), .default_branch] | @tsv' \
> "$response"; then
err "HOMEBREW_PR_TOKEN could not authenticate to $REPOSITORY"
fi
scopes="$(
awk '
tolower($0) ~ /^x-oauth-scopes:/ {
sub(/^[^:]*:[[:space:]]*/, "")
sub(/\r$/, "")
print
exit
}
' "$response"
)"
credential_kind="fine-grained"
if [ -n "$scopes" ]; then
[ "$scopes" = "public_repo" ] || {
err "classic HOMEBREW_PR_TOKEN must have only public_repo scope"
}
credential_kind="classic-public_repo"
fi
repository_state="$(tail -n 1 "$response")"
actual_repository="$(printf '%s\n' "$repository_state" | cut -f1)"
can_push="$(printf '%s\n' "$repository_state" | cut -f2)"
default_branch="$(printf '%s\n' "$repository_state" | cut -f3)"
[ "$actual_repository" = "$REPOSITORY" ] || {
err "HOMEBREW_PR_TOKEN resolved unexpected repository $actual_repository"
}
[ "$can_push" = "true" ] || {
err "HOMEBREW_PR_TOKEN owner does not have push permission to $REPOSITORY"
}
[ -n "$default_branch" ] || err "HOMEBREW_PR_TOKEN returned an empty default branch"
actor="$(
GH_TOKEN="$TOKEN" gh api \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
user \
--jq '.login'
)" || err "HOMEBREW_PR_TOKEN could not resolve its owner"
[ -n "$actor" ] || err "HOMEBREW_PR_TOKEN returned an empty owner"
GH_TOKEN="$TOKEN" gh api \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$REPOSITORY/pulls?state=open&per_page=1" \
--silent \
|| err "HOMEBREW_PR_TOKEN cannot access pull requests in $REPOSITORY"
if [ "$MODE" = "--canary" ]; then
command -v git >/dev/null 2>&1 || err "git is required to run the Homebrew token canary"
nonce="$(basename "$TMP_ROOT" | tr -cd 'A-Za-z0-9')"
run_id="${GITHUB_RUN_ID:-manual-$(date +%s)-$nonce}"
run_attempt="${GITHUB_RUN_ATTEMPT:-1}"
case "$run_id-$run_attempt" in
*[!A-Za-z0-9_-]*) err "invalid Homebrew token canary identity" ;;
esac
CANARY_BRANCH="automation/homebrew-token-canary-$run_id-$run_attempt"
CANARY_ROOT="$TMP_ROOT/canary"
canonical_remote="https://github.com/$REPOSITORY.git"
askpass="$TMP_ROOT/git-askpass.sh"
printf '%s\n' \
'#!/bin/sh' \
'case "$1" in' \
' *Username*) printf "%s\n" "x-access-token" ;;' \
' *) printf "%s\n" "$HOMEBREW_PR_TOKEN" ;;' \
'esac' > "$askpass"
chmod 700 "$askpass"
export HOMEBREW_PR_TOKEN GIT_ASKPASS="$askpass" GIT_TERMINAL_PROMPT=0
export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_SYSTEM=/dev/null GIT_CONFIG_GLOBAL=/dev/null
export GIT_CONFIG_COUNT=0
git_with_homebrew_token() {
git -c credential.helper= -c http.extraHeader= "$@"
}
find_canary_pr_number() {
owner="${REPOSITORY%%/*}"
numbers="$(
GH_TOKEN="$TOKEN" gh api \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$REPOSITORY/pulls?state=open&head=$owner:$CANARY_BRANCH&base=$default_branch&per_page=2" \
--jq '.[].number'
)" || return 1
case "$numbers" in
"") return 0 ;;
*[!0-9]*) return 1 ;;
*) printf '%s\n' "$numbers" ;;
esac
}
close_canary_pr() {
GH_TOKEN="$TOKEN" gh api \
-X PATCH \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$REPOSITORY/pulls/$1" \
-f state=closed \
--silent
}
delete_canary_branch() {
[ -n "$CANARY_COMMIT" ] || return 1
remote_ref="$(
git_with_homebrew_token -C "$CANARY_ROOT" ls-remote \
--heads "$canonical_remote" "refs/heads/$CANARY_BRANCH"
)" || return 1
[ -n "$remote_ref" ] || return 0
remote_sha="$(printf '%s\n' "$remote_ref" | awk 'NR == 1 { print $1 }')"
[ "$remote_sha" = "$CANARY_COMMIT" ] || return 1
git_with_homebrew_token -C "$CANARY_ROOT" push \
--force-with-lease="refs/heads/$CANARY_BRANCH:$CANARY_COMMIT" \
"$canonical_remote" ":refs/heads/$CANARY_BRANCH"
}
git_with_homebrew_token clone \
--depth=1 \
--no-tags \
--single-branch \
--branch "$default_branch" \
"$canonical_remote" \
"$CANARY_ROOT" >/dev/null \
|| err "HOMEBREW_PR_TOKEN cannot clone $REPOSITORY over canonical HTTPS"
git -C "$CANARY_ROOT" checkout -b "$CANARY_BRANCH" >/dev/null \
|| err "could not create local Homebrew token canary branch"
mkdir -p "$CANARY_ROOT/.dws"
printf 'run=%s attempt=%s actor=%s\n' "$run_id" "$run_attempt" "$actor" \
> "$CANARY_ROOT/.dws/homebrew-token-canary.txt"
git -C "$CANARY_ROOT" config user.name "DWS Release Bot"
git -C "$CANARY_ROOT" config user.email "dws-release-bot@example.com"
git -C "$CANARY_ROOT" add .dws/homebrew-token-canary.txt
git -C "$CANARY_ROOT" commit -m "chore: verify Homebrew token [skip ci]" >/dev/null \
|| err "could not create Homebrew token canary commit"
CANARY_COMMIT="$(git -C "$CANARY_ROOT" rev-parse HEAD)"
CANARY_BRANCH_MAY_EXIST="true"
git_with_homebrew_token -C "$CANARY_ROOT" push \
"$canonical_remote" "HEAD:refs/heads/$CANARY_BRANCH" >/dev/null \
|| err "HOMEBREW_PR_TOKEN cannot push a canary branch to $REPOSITORY"
CANARY_PR_MAY_EXIST="true"
canary_pr_url="$(
GH_TOKEN="$TOKEN" gh pr create \
--repo "$REPOSITORY" \
--base "$default_branch" \
--head "$CANARY_BRANCH" \
--title "chore: verify Homebrew PR token" \
--body "Automated permission canary. This PR is closed and its branch deleted by the same workflow run." \
--draft
)" || err "HOMEBREW_PR_TOKEN cannot create a canary pull request in $REPOSITORY"
CANARY_PR_NUMBER="${canary_pr_url##*/}"
case "$CANARY_PR_NUMBER" in
""|*[!0-9]*) err "Homebrew token canary returned an invalid pull request URL" ;;
esac
close_canary_pr "$CANARY_PR_NUMBER" \
|| err "HOMEBREW_PR_TOKEN could not close canary PR #$CANARY_PR_NUMBER"
CANARY_PR_MAY_EXIST="false"
CANARY_PR_NUMBER=""
delete_canary_branch >/dev/null \
|| err "HOMEBREW_PR_TOKEN could not delete canary branch $CANARY_BRANCH"
CANARY_BRANCH_MAY_EXIST="false"
fi
printf 'Homebrew PR token capability passed for %s as %s (%s)\n' \
"$REPOSITORY" "$actor" "$credential_kind"
+99
View File
@@ -0,0 +1,99 @@
#!/bin/sh
set -eu
REPOSITORY="${1:-}"
COMMIT="${2:-}"
WORKFLOW="release.yml"
DEFAULT_BRANCH="main"
TIMEOUT_SECONDS="${DWS_RELEASE_GOVERNANCE_TIMEOUT_SECONDS:-180}"
POLL_SECONDS="${DWS_RELEASE_GOVERNANCE_POLL_SECONDS:-2}"
usage() {
printf '%s\n' 'usage: verify-release-governance-ci.sh <owner/repository> <commit-sha>' >&2
}
[ -n "$REPOSITORY" ] && [ -n "$COMMIT" ] || { usage; exit 2; }
printf '%s\n' "$COMMIT" | grep -Eq '^[0-9a-f]{40}$' || {
printf 'invalid release commit: %s\n' "$COMMIT" >&2
exit 2
}
printf '%s\n' "$TIMEOUT_SECONDS" | grep -Eq '^[1-9][0-9]*$' || {
printf 'invalid governance timeout: %s\n' "$TIMEOUT_SECONDS" >&2
exit 2
}
printf '%s\n' "$POLL_SECONDS" | grep -Eq '^[1-9][0-9]*$' || {
printf 'invalid governance poll interval: %s\n' "$POLL_SECONDS" >&2
exit 2
}
command -v gh >/dev/null 2>&1 || {
printf '%s\n' 'gh is required to run the Release governance preflight' >&2
exit 1
}
nonce="${COMMIT}-$(date +%s)-$$"
expected_title="Release governance preflight $nonce"
printf '==> Dispatching Release governance preflight for %s\n' "$COMMIT"
gh workflow run "$WORKFLOW" \
--repo "$REPOSITORY" \
--ref "$DEFAULT_BRANCH" \
-f "governance_preflight_commit=$COMMIT" \
-f "governance_preflight_nonce=$nonce"
started_at="$(date +%s)"
run_id=""
while :; do
# nonce is restricted to a commit SHA, decimal timestamp, PID, and hyphens,
# so it is safe to embed in this jq string literal.
run_id="$(
gh api \
-H 'Accept: application/vnd.github+json' \
"repos/$REPOSITORY/actions/workflows/$WORKFLOW/runs?event=workflow_dispatch&branch=$DEFAULT_BRANCH&per_page=100" \
--jq ".workflow_runs[] | select(.display_title == \"$expected_title\" and .head_sha == \"$COMMIT\") | .id" \
| sed -n '1p'
)" || {
printf '%s\n' 'could not query the dispatched Release governance preflight' >&2
exit 1
}
[ -z "$run_id" ] || break
now="$(date +%s)"
if [ $((now - started_at)) -ge "$TIMEOUT_SECONDS" ]; then
printf 'timed out waiting for Release governance preflight: %s\n' "$expected_title" >&2
exit 1
fi
sleep "$POLL_SECONDS"
done
run_state=""
while :; do
run_state="$(
gh api \
-H 'Accept: application/vnd.github+json' \
"repos/$REPOSITORY/actions/runs/$run_id" \
--jq '[.display_title, .event, .status, .conclusion, .head_branch, .head_sha, .path, .repository.full_name] | @tsv'
)" || {
printf 'could not verify Release governance preflight run %s\n' "$run_id" >&2
exit 1
}
run_status="$(printf '%s\n' "$run_state" | cut -f3)"
[ "$run_status" != "completed" ] || break
now="$(date +%s)"
if [ $((now - started_at)) -ge "$TIMEOUT_SECONDS" ]; then
printf 'timed out waiting for Release governance preflight run %s\n' "$run_id" >&2
exit 1
fi
sleep "$POLL_SECONDS"
done
expected_state="$(printf '%s\tworkflow_dispatch\tcompleted\tsuccess\t%s\t%s\t.github/workflows/release.yml\t%s' \
"$expected_title" "$DEFAULT_BRANCH" "$COMMIT" "$REPOSITORY")"
[ "$run_state" = "$expected_state" ] || {
printf 'Release governance preflight run identity mismatch:\n expected: %s\n actual: %s\n' \
"$expected_state" "$run_state" >&2
gh run view "$run_id" --repo "$REPOSITORY" --log-failed >&2 || true
exit 1
}
printf 'Release governance preflight passed: https://github.com/%s/actions/runs/%s\n' \
"$REPOSITORY" "$run_id"
+156
View File
@@ -0,0 +1,156 @@
#!/bin/sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
. "$SCRIPT_DIR/release-lib.sh"
TAG="${1:-}"
EXPECTED_COMMIT="${2:-}"
REPOSITORY="${DWS_RELEASE_OFFICIAL_REPOSITORY:-DingTalk-Real-AI/dingtalk-workspace-cli}"
[ -n "$TAG" ] && [ -n "$EXPECTED_COMMIT" ] || {
printf 'usage: verify-release-workflow-delivery.sh <tag> <commit>\n' >&2
exit 2
}
if ! release_is_stable_version "$TAG" && ! release_is_prerelease_version "$TAG"; then
printf 'invalid delivered release tag: %s\n' "$TAG" >&2
exit 2
fi
printf '%s\n' "$EXPECTED_COMMIT" | grep -Eq '^[0-9a-f]{40}$' || {
printf 'invalid delivered release commit: %s\n' "$EXPECTED_COMMIT" >&2
exit 2
}
command -v curl >/dev/null 2>&1 || { printf '%s\n' 'curl is required to verify release delivery' >&2; exit 1; }
command -v python3 >/dev/null 2>&1 || { printf '%s\n' 'python3 is required to verify release delivery' >&2; exit 1; }
API_TOKEN="${DWS_RELEASE_GITHUB_TOKEN:-}"
if [ -z "$API_TOKEN" ] && [ "${GITHUB_ACTIONS:-false}" != "true" ] && command -v gh >/dev/null 2>&1; then
API_TOKEN="$(gh auth token 2>/dev/null || true)"
fi
if [ -z "$API_TOKEN" ]; then API_TOKEN="${GITHUB_TOKEN:-}"; fi
github_get() {
if [ -n "$API_TOKEN" ]; then
curl -fsSL \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
-H "Authorization: Bearer $API_TOKEN" \
"https://api.github.com/$1" 2>/dev/null && return 0
fi
curl -fsSL \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"https://api.github.com/$1"
}
find_push_delivery() {
page=1
while :; do
page_result="$(
github_get "repos/$REPOSITORY/actions/workflows/release.yml/runs?branch=$TAG&event=push&status=completed&per_page=100&page=$page" \
| python3 -c 'import json,sys
tag,commit=sys.argv[1:]
runs=json.load(sys.stdin).get("workflow_runs", [])
print(len(runs))
for run in runs:
if run.get("head_sha") == commit and run.get("head_branch") == tag and run.get("conclusion") == "success":
print(run.get("id", ""))
break' "$TAG" "$EXPECTED_COMMIT"
)" || return 1
page_count="$(printf '%s\n' "$page_result" | sed -n '1p')"
page_match="$(printf '%s\n' "$page_result" | sed -n '2p')"
if [ -n "$page_match" ]; then printf '%s\n' "$page_match"; return 0; fi
[ "$page_count" -eq 100 ] || return 1
page=$((page + 1))
done
}
push_delivery="$(find_push_delivery || true)"
if [ -n "$push_delivery" ]; then
printf 'Release workflow delivery verified through exact-tag push run %s: %s -> %s\n' \
"$push_delivery" "$TAG" "$EXPECTED_COMMIT"
exit 0
fi
find_recovery_identity() {
page=1
while :; do
page_result="$(
github_get "repos/$REPOSITORY/actions/workflows/release.yml/runs?branch=main&event=workflow_dispatch&status=completed&per_page=100&page=$page" \
| python3 -c 'import json,sys
tag,commit,repository=sys.argv[1:]
title=f"Release recovery {tag} at {commit}"
runs=json.load(sys.stdin).get("workflow_runs", [])
print(len(runs))
for run in runs:
display=run.get("display_title", "")
nonce=display[len(title) + 1:] if display.startswith(title + " ") else ""
if (__import__("re").fullmatch(__import__("re").escape(commit) + r"-[0-9]+-[0-9]+", nonce)
and run.get("event") == "workflow_dispatch"
and run.get("status") == "completed" and run.get("conclusion") == "success"
and run.get("head_branch") == "main" and run.get("path") == ".github/workflows/release.yml"
and run.get("repository", {}).get("full_name") == repository):
print("%s\t%s" % (run.get("id", ""), run.get("head_sha", "")))
break' "$TAG" "$EXPECTED_COMMIT" "$REPOSITORY"
)" || return 1
page_count="$(printf '%s\n' "$page_result" | sed -n '1p')"
page_match="$(printf '%s\n' "$page_result" | sed -n '2p')"
if [ -n "$page_match" ]; then printf '%s\n' "$page_match"; return 0; fi
[ "$page_count" -eq 100 ] || return 1
page=$((page + 1))
done
}
recovery_identity="$(find_recovery_identity || true)"
[ -n "$recovery_identity" ] || {
printf 'Release workflow did not deliver %s at %s through a tag push or protected recovery\n' \
"$TAG" "$EXPECTED_COMMIT" >&2
exit 1
}
recovery_run_id="$(printf '%s\n' "$recovery_identity" | cut -f1)"
recovery_workflow_sha="$(printf '%s\n' "$recovery_identity" | cut -f2)"
workflow_status="$({
github_get "repos/$REPOSITORY/compare/$recovery_workflow_sha...main" \
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("status", ""))'
} || true)"
case "$workflow_status" in ahead|identical) ;; *)
printf 'protected recovery workflow %s is not contained in current main\n' \
"$recovery_workflow_sha" >&2
exit 1
esac
passed_jobs=""
page=1
while :; do
page_result="$(
github_get "repos/$REPOSITORY/actions/runs/$recovery_run_id/jobs?filter=all&per_page=100&page=$page" \
| python3 -c 'import json,sys
workflow_sha=sys.argv[1]
jobs=json.load(sys.stdin).get("jobs", [])
print(len(jobs))
for job in jobs:
if (job.get("status") == "completed" and job.get("conclusion") == "success"
and job.get("head_sha") == workflow_sha):
print(job.get("name", ""))' "$recovery_workflow_sha"
)" || exit 1
page_count="$(printf '%s\n' "$page_result" | sed -n '1p')"
page_jobs="$(printf '%s\n' "$page_result" | sed '1d')"
passed_jobs="$(printf '%s\n%s\n' "$passed_jobs" "$page_jobs" | sed '/^$/d')"
[ "$page_count" -eq 100 ] || break
page=$((page + 1))
done
for required_job in \
"Build signed release artifacts" \
"Verify Apple Developer ID signatures" \
"Publish immutable GitHub Release" \
"Publish npm and mirrors"; do
printf '%s\n' "$passed_jobs" | grep -Fqx "$required_job" || {
printf 'protected recovery run %s did not complete the shared release job graph for %s\n' \
"$recovery_run_id" "$TAG" >&2
exit 1
}
done
printf 'Release workflow delivery verified through protected recovery run %s: %s -> %s\n' \
"$recovery_run_id" "$TAG" "$EXPECTED_COMMIT"
+2 -2
View File
@@ -92,7 +92,7 @@ cli_version: ">=1.0.15"
| `sheet` | 在线电子表格(axls):工作表 CRUD/区域读写/CSV 批量写入/行列增删/合并/查找替换/筛选视图/全局筛选/排序/下拉列表/条件格式/浮动图片/浮动图表/模板/导出 xlsx(单命令一站式) | [sheet.md](./references/products/sheet.md) |
| `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) |
| `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 | [wiki.md](./references/products/wiki.md) |
| `event` | 个人消息事件:监听当前用户被 @、指定单聊、指定群聊,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) |
| `event` | 个人 IM 事件:监听消息接收、指定发送人、已读、撤回、表情回应,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) |
## 意图判断决策树
@@ -116,7 +116,7 @@ cli_version: ">=1.0.15"
用户提到"在线电子表格/钉钉表格/axls/工作表/单元格读写/合并单元格/筛选视图/导出 xlsx" → `sheet`
用户提到"待办/TODO/任务提醒/循环待办" → `todo`
用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki`
用户提到"监听有人@我/监听我和某人的单聊消息/监听某个群消息/订阅个人消息事件/实时接收钉钉消息事件/个人消息事件流/event consume user_im_message_receive_at/user_im_message_receive_o2o/user_im_message_receive_group/监听并自动回复消息/驱动 Agent 处理消息" → `event`
用户提到"监听有人@我/监听单聊或群消息/监听某人发送的消息/监听消息已读/监听消息撤回/监听消息贴表情或表情回应/订阅个人 IM 事件/实时接收钉钉事件/个人事件流/event consume user_im_message_*/监听并自动回复消息/驱动 Agent 处理消息" → `event`
关键区分: aitable(数据表格) vs todo(待办任务)
关键区分: report(钉钉日志/日报周报) vs todo(待办任务)
+78 -32
View File
@@ -1,6 +1,6 @@
# dws event — 个人消息事件
# dws event — 个人 IM 事件
监听当前用户收到的钉钉消息事件,NDJSON 输出到 stdout。实时监听 / 自动回复 / 订阅一律用 `dws event consume`,不要轮询消息历史。
通过个人 Stream 长连接监听当前用户的钉钉消息接收、已读、撤回和表情回应事件,NDJSON 输出到 stdout,用于驱动事件触发的 Agent。实时监听、自动回复、订阅事件都必须使用 `dws event consume`,不要写脚本轮询消息历史。
## 运行方式
@@ -26,44 +26,89 @@
| 事件码 | 场景 | 必填参数 |
|---|---|---|
| `user_im_message_receive_at` | 当前用户被 @ 的消息 | 无 |
| `user_im_message_receive_o2o` | 与指定用户的单聊消息 | `--user` |
| `user_im_message_receive_group` | 指定群聊 / 会话的消息 | `--group` |
| `user_im_message_receive_o2o` | 当前用户与指定用户的单聊消息 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_receive_group` | 当前用户所在指定群聊/会话的消息 | `--group` |
| `user_im_message_receive_user` | 当前用户收到的指定用户发送的消息(单聊和群聊) | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_read_o2o` | 指定单聊中当前用户发送的消息被已读 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_read_group` | 指定群聊中当前用户发送的消息被已读 | `--group` |
| `user_im_message_recall_o2o` | 指定单聊中的消息被撤回 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_recall_group` | 指定群聊中的消息被撤回 | `--group` |
| `user_im_message_reaction_o2o` | 指定单聊中的消息收到表情回应 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_reaction_group` | 指定群聊中的消息收到表情回应 | `--group` |
只承认这 3 个事件码。默认身份即当前用户,不加身份切换 flag。
只承认上表 10 个事件码。默认身份就是当前用户,不要额外加身份切换 flag。
## Intent mapping
| 用户说 | 下一步 |
|---|---|
| 监听有人 @ 我 | consume `user_im_message_receive_at` |
| 监听我和某用户的单聊 | 解析对端 userId,consume `user_im_message_receive_o2o --user <id>` |
| 监听某群 | 先 `dws chat search --query "<群名>"` 拿 openConversationId,再 consume group |
| 监听并自动回复某人单聊 | 解析对端 userId,启动 o2o consume;回复用 `dws chat message send` |
| 查看事件 schema | `dws event schema <event_key>` |
| 看订阅状态 | `dws event status --event <event_key>` |
| 停止订阅 | `dws event stop <subscribe_id> --dry-run`,确认后改用 `--yes` |
| "监听有人 @ 我的消息" | `event consume`,事件码 `user_im_message_receive_at`,参数 `-f ndjson` |
| "监听我和 userId test-user-001 的单聊消息" | `event consume`,事件码 `user_im_message_receive_o2o`,参数 `--user test-user-001 -f ndjson` |
| "监听我和 openDingtalkId abc 的单聊消息" | `event consume`,事件码 `user_im_message_receive_o2o`,参数 `--open-dingtalk-id abc -f ndjson` |
| "监听 XX 群消息" | 先 `dws chat search --query "XX" --format json`,确认后 consume group |
| "监听 userId test-user-001 发给我的消息" | `event consume`,事件码 `user_im_message_receive_user`,参数 `--user test-user-001 -f ndjson` |
| "监听 openDingtalkId abc 发给我的消息" | `event consume`,事件码 `user_im_message_receive_user`,参数 `--open-dingtalk-id abc -f ndjson` |
| "监听我发给 userId test-user-001 的消息是否已读" | `event consume`,事件码 `user_im_message_read_o2o`,参数 `--user test-user-001 -f ndjson` |
| "监听 XX 群消息已读" | 先解析群 ID,再 consume `user_im_message_read_group --group <id>` |
| "监听我和 userId test-user-001 的消息撤回" | `event consume`,事件码 `user_im_message_recall_o2o`,参数 `--user test-user-001 -f ndjson` |
| "监听 XX 群消息撤回" | 先解析群 ID,再 consume `user_im_message_recall_group --group <id>` |
| "监听我和 userId test-user-001 的消息贴表情" | `event consume`,事件码 `user_im_message_reaction_o2o`,参数 `--user test-user-001 -f ndjson` |
| "监听 XX 群消息表情回应" | 先解析群 ID,再 consume `user_im_message_reaction_group --group <id>` |
| "监听并自动回复某人的单聊消息" | 先解析对端 userId,再启动 o2o consume;不要写轮询脚本 |
| "查看个人消息事件 schema" | `dws event schema <event_key>` |
| "看个人事件订阅状态" | `dws event status --event <event_key>` |
| "停止这个个人事件订阅" | `dws event stop <subscribe_id> --dry-run`,确认后改用 `--yes` |
多候选让用户确认。缺必填 ID 且解析不出先追问,不要猜。
多候选让用户确认。缺必填 ID 且解析不出先追问,不要猜。企业内部 userId 使用 `--user`;明确给出 openDingtalkId,或目标是外部联系人、机器人、跨组织身份时使用 `--open-dingtalk-id`。两者严格二选一,不得混填、猜测或自动转换身份类型。
“我和某人的单聊”使用 `receive_o2o`;“某人发给我的消息/某人发送的消息”使用 `receive_user`,后者覆盖该发送人的单聊和群聊消息。用户要求执行“撤回消息”时走 `dws chat`;只有“监听/订阅消息撤回”才走 `dws event`。“贴标签”表示给消息贴表情时,对应 `reaction` 表情回应事件。
## Call flow
1. 按意图选事件码;人名 / 群名先解析成必填 ID。
2. 需要字段时 `dws event schema <event_key>`,读 `jq_root_path` 和 `schema.properties`。
3. 启动 `dws event consume <event_key> ... -f ndjson`,等 stderr 出现 `[event] ready event_key=<key> ...` 再读 stdout,不要 sleep。
4. stdout 每行一个事件 JSON;`data` 字段是 JSON 字符串,按 `jq_root_path` 再 parse。
5. `dws event status --event <event_key>` 看 Subscriptions / Consumers。
6. 取消订阅先运行 `dws event stop <subscribe_id> --dry-run`,向用户确认预览后再以 `--yes` 执行;自测可在 consume 加 `--max-events` / `--duration` 自动退出。
1. 从用户意图选择事件码;人名或群名先解析成必填 ID。
2. 需要了解字段时运行 `dws event schema <event_key>`,读取 `schema.properties`;`jq_root_path` 当前固定为 `.`。
3. 启动 `dws event consume <event_key> ... -f ndjson`,等待 stderr 出现 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>` 后开始处理 stdout,不要用 `sleep` 猜测。
4. stdout 每行是一个扁平事件 JSON;直接按该事件的 `schema.properties` 读取顶层字段。
5. 需要确认监听状态时运行 `dws event status --event <event_key>`,查看 `Subscriptions` 和 `Consumers`。
6. 任务完成后优雅结束 consume;本次新建的订阅会自动取消。复用已有订阅或需要从外部主动取消时,先运行 `dws event stop <subscribe_id> --dry-run`,向用户确认后再以 `--yes` 执行;自测可在 consume 加 `--max-events` 或 `--duration` 自动退出。
## Commands
```bash
dws event schema user_im_message_receive_at
dws event schema user_im_message_receive_o2o
dws event schema user_im_message_receive_group
dws event schema user_im_message_receive_user
dws event schema user_im_message_read_o2o
dws event schema user_im_message_read_group
dws event schema user_im_message_recall_o2o
dws event schema user_im_message_recall_group
dws event schema user_im_message_reaction_o2o
dws event schema user_im_message_reaction_group
```
```bash
dws event consume user_im_message_receive_at -f ndjson
dws event consume user_im_message_receive_o2o --user 507971 -f ndjson
dws event consume user_im_message_receive_o2o --user test-user-001 -f ndjson
dws event consume user_im_message_receive_o2o --open-dingtalk-id abc -f ndjson
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
dws event consume user_im_message_receive_user --user test-user-001 -f ndjson
dws event consume user_im_message_receive_user --open-dingtalk-id abc -f ndjson
dws event consume user_im_message_read_o2o --user test-user-001 -f ndjson
dws event consume user_im_message_read_group --group <openConversationId> -f ndjson
dws event consume user_im_message_recall_o2o --user test-user-001 -f ndjson
dws event consume user_im_message_recall_group --group <openConversationId> -f ndjson
dws event consume user_im_message_reaction_o2o --user test-user-001 -f ndjson
dws event consume user_im_message_reaction_group --group <openConversationId> -f ndjson
```
上述所有 `*_o2o` 命令和 `user_im_message_receive_user` 都可将 `--user <userId>` 替换为 `--open-dingtalk-id <openDingtalkId>`,但两个参数不能同时使用。
```bash
dws event status --event user_im_message_receive_at
dws event status --event user_im_message_receive_o2o
dws event status --event user_im_message_receive_group
dws event status --event user_im_message_receive_user
dws event stop <subscribe_id> --dry-run
dws event stop <subscribe_id> --yes
dws event stop --all --dry-run
@@ -72,7 +117,7 @@ dws event stop --all --yes
## Subprocess contract
- 就绪:连上后 stderr 打 `[event] ready event_key=<key> bus_pid=<pid>`,父进程等这行再读 stdout。不要 `--quiet`(会抑制它)。
- 就绪:连上后 stderr 打 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>`,父进程等这行再读 stdout。不要 `--quiet`(会抑制它)。
- 退出:末行 `[event] exited — received N event(s) in Xs (reason: limit|timeout|signal|bus_shutdown)`;受控退出码 0,失败非 0 且无 exited 行、有 Error 行。
- stdin 关闭 = 停机:仅当 stdin 是管道且未设 `--max-events/--duration` 时生效;交互终端和 `< /dev/null` 不触发。用管道 stdin 又要常驻就喂 `< <(tail -f /dev/null)`。
- 订阅清理:本次新建的订阅任意退出即自动退订;`--subscribe-id` 复用的保留;`--ephemeral` 强制退订。优雅停用 SIGTERM、关 stdin,或外部先预览 `dws event stop <subscribe_id> --dry-run`、确认后加 `--yes`。不要 `kill -9`(跳过退订、泄漏服务端订阅)。
@@ -80,17 +125,18 @@ dws event stop --all --yes
## Output parsing
- 用 `-f ndjson`,一行一个事件 JSON。抓样本用 `-f json --max-events 1`。
- 两层解析:外层事件 JSON 的 `data` 字段是 JSON 字符串,`fromjson` 后取 `payload.body.content`(正文)/ `payload.body.sender`(发送人)/ `payload.body.openConversationId`(会话)。样例:
```json
{"type":"event","event_type":"user_im_message_receive_group",
"data":"{\"payload\":{\"body\":{\"sender\":\"张三\",\"content\":\"你好\",\"openConversationId\":\"cid...==\"}},\"subject\":{\"isSelfLoop\":false}}"}
```
- 自己发的消息不作为事件回来(`isSelfLoop` 过滤):边听边回不成环;自发验证会看到 0 事件,测投递用别人 / 机器人发。
- `--jq <表达式>` 把过滤 / 投影下推到 consume,减少输出。
- `--debug-raw-events` 仅联调用。
- 推荐 `-f ndjson`:一行一个事件 JSON,适合 Agent 管道读取。
- 人工取样可用 `-f json --max-events 1`。
- `jq_root_path` 当前为 `.`;消息正文、发送人和会话 ID 分别直接读取顶层 `content`、`sender`、`conversation_id`。
- 不要生成 `fromjson` 或内部 payload 路径。正常处理直接持续读取 stdout,不要改写为 `--output-dir` watcher。
- 群自动回复使用顶层 `conversation_id`;单聊自动回复使用顶层 `sender_open_dingtalk_id`。
- 已读事件直接读取 `reader/reader_open_dingtalk_id/read_time`;撤回事件读取 `recaller/recaller_open_dingtalk_id/recall_time`。
- 表情回应事件直接读取 `operator/operator_open_dingtalk_id/reaction_name/reaction_text/operation_type/operation_time`。
- 图片、文件等媒体消息的 `content` 可能是可读描述;需要实际媒体文件时调用 `dws chat message download-media`。
- 正常动作事件输出不含内部 `payload/uid/corpid/clientId/filterSubId/bizid`;原始排查才使用 `-f raw` 或 `--debug-raw-events`。
- 自己发的消息不作为事件回来(`isSelfLoop` 过滤);自发验证会看到 0 事件,测试投递使用别人或机器人发消息。
- `--jq <表达式>` 可进一步过滤或投影扁平输出。
- `--debug-raw-events` 仅用于服务端联调,正常消费不要使用。
## Troubleshooting
+63 -14
View File
@@ -1,9 +1,9 @@
---
name: dingtalk-event
description: 钉钉个人消息事件长连接监听、订阅与消费,输出 NDJSON 到 stdout。Use when 用户提到 监听个人消息事件、被@消息、监听我和某人的单聊消息、监听某个群消息、实时接收钉钉消息事件、dws event consume user_im_message_receive_at/user_im_message_receive_o2o/user_im_message_receive_group、用事件驱动 Agent 处理钉钉消息、监听并自动回复消息。命令前缀:dws event。
description: 钉钉个人 IM 事件长连接监听、订阅与消费,覆盖消息接收、指定发送人、已读、撤回和表情回应,输出 NDJSON 到 stdout。Use when 用户提到 监听个人消息事件、被@消息、监听单聊或群消息、监听某人发送的消息、监听消息已读、监听消息撤回、监听消息贴表情或表情回应、实时接收钉钉事件、用事件驱动 Agent。命令前缀:dws event。
---
# 钉钉个人消息事件
# 钉钉个人 IM 事件
只使用 `dws event consume` 建立个人消息事件长连接。用户要求实时监听、订阅、自动回复或驱动 Agent 时,不要写轮询脚本,不要用消息历史查询模拟事件。
@@ -32,10 +32,17 @@ description: 钉钉个人消息事件长连接监听、订阅与消费,输出
| 事件码 | 场景 | 必填参数 |
|---|---|---|
| `user_im_message_receive_at` | 当前用户被 @ 的消息 | 无 |
| `user_im_message_receive_o2o` | 当前用户与指定用户的单聊消息 | `--user` |
| `user_im_message_receive_o2o` | 当前用户与指定用户的单聊消息 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_receive_group` | 当前用户所在指定群聊/会话的消息 | `--group` |
| `user_im_message_receive_user` | 当前用户收到的指定用户发送的消息(单聊和群聊) | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_read_o2o` | 指定单聊中当前用户发送的消息被已读 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_read_group` | 指定群聊中当前用户发送的消息被已读 | `--group` |
| `user_im_message_recall_o2o` | 指定单聊中的消息被撤回 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_recall_group` | 指定群聊中的消息被撤回 | `--group` |
| `user_im_message_reaction_o2o` | 指定单聊中的消息收到表情回应 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_reaction_group` | 指定群聊中的消息收到表情回应 | `--group` |
只承认上表 3 个事件码。其它身份模式、应用凭证模式、非个人消息事件不在本 skill 范围内。
只承认上表 10 个事件码。其它身份模式、应用凭证模式、非个人 IM 事件不在本 skill 范围内。
## Command rules
@@ -44,7 +51,12 @@ description: 钉钉个人消息事件长连接监听、订阅与消费,输出
- 不主动运行 `dws event list` 作为能力菜单;按用户意图直接选择上表事件。
- 缺少必填 ID 时先解析或追问,不要猜测 ID。
- 用户只给单聊对端人名时,先运行 `dws aisearch person --keyword "<name>" --dimension name --format json` 解析 userId;多候选必须让用户确认。
- 企业内部 userId 使用 `--user`;用户明确提供 openDingtalkId,或目标是外部联系人、机器人、跨组织身份时,使用 `--open-dingtalk-id`。
- `--user` 与 `--open-dingtalk-id` 严格二选一。不要把 openDingtalkId 填入 `--user`,不要自动猜测或转换身份类型;缺少外部目标的 openDingtalkId 时先追问。
- “监听我和某人的单聊”使用 `user_im_message_receive_o2o`;“监听某人发给我的消息/监听某人发送的消息”使用 `user_im_message_receive_user`,后者覆盖该发送人的单聊和群聊消息。
- 用户只给群名时,先运行 `dws chat search --query "<group>" --format json` 解析 openConversationId;多候选必须让用户确认。
- 用户要求执行“撤回消息”时使用 `dws chat`;只有“监听/订阅消息撤回”才使用 `dws event consume user_im_message_recall_*`。
- 用户说“贴标签”且语义是给消息贴表情时,按消息表情回应事件处理,event key 使用 `reaction`。
- 正常 Agent 消费使用 `-f ndjson`。抓一条样本可用 `--max-events 1 -f json`。
- 监听非默认组织时带 `--profile <corpId 或 profile 名>`;漏传会退回默认 profile 而失败。
- 自己发的消息不作为事件回来(`isSelfLoop` 过滤):边监听边 `dws chat message send` 回复不成环;测试投递用别人 / 机器人发(自发会看到 0 事件)。
@@ -54,18 +66,19 @@ description: 钉钉个人消息事件长连接监听、订阅与消费,输出
## Call flow
1. 从用户意图选择事件码;人名或群名先解析成必填 ID。
2. 需要了解字段时运行 `dws event schema <event_key>`,读取 `jq_root_path` 和 `schema.properties`。
3. 启动 `dws event consume <event_key> ... -f ndjson`,阻塞等 stderr 出现固定就绪行 `[event] ready event_key=<key> ...` 后再开始读 stdout(不要用 sleep 猜)。
4. stdout 每行是一个事件 JSON;业务字段在 `data` JSON 字符串内,按 `jq_root_path` 解析。
2. 需要了解字段时运行 `dws event schema <event_key>`,读取 `schema.properties`;`jq_root_path` 当前固定为 `.`。
3. 启动 `dws event consume <event_key> ... -f ndjson`,等待 stderr 出现 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>` 后开始处理 stdout,不要用 `sleep` 猜测。
4. stdout 每行是一个扁平事件 JSON;直接按该事件的 `schema.properties` 读取顶层字段。
5. 需要确认监听状态时运行 `dws event status --event <event_key>`,查看 `Subscriptions` 和 `Consumers`。
6. 任务完成后先用 `dws event stop <subscribe_id> --dry-run` 预览,向用户确认后再加 `--yes` 取消订阅;如果是临时测试,可以在 consume 上加 `--max-events` 或 `--duration` 自动退出。
6. 任务完成后优雅结束 consume;本次新建的订阅会自动取消。复用已有订阅或需要从外部主动取消时,先用 `dws event stop <subscribe_id> --dry-run` 预览,向用户确认后再加 `--yes`;临时测试可用 `--max-events` 或 `--duration` 自动退出。
## Subprocess contract
- `event consume` 阻塞式长连接。stdout 只出事件;stderr 只出状态 / debug / 错误。
- 就绪:连上后 stderr 打 `[event] ready event_key=<key> bus_pid=<pid>`,父进程等这行再读 stdout。不要 `--quiet`(会抑制它)。
- 就绪:连上后 stderr 打 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>`,父进程等这行再读 stdout。不要 `--quiet`(会抑制它)。
- 退出:末行 `[event] exited — received N event(s) in Xs (reason: limit|timeout|signal|bus_shutdown)`;受控退出码 0,失败非 0 无 exited 行。
- stdin 关闭 = 停机:仅当 stdin 是管道且未设 `--max-events/--duration` 时生效;交互终端和 `< /dev/null` 不触发。用管道 stdin 又要常驻就喂 `< <(tail -f /dev/null)`。
- 正常事件处理持续读取 stdout 管道,不要改写为 `--output-dir` watcher。
- 无界监听需外部进程管理;有界自测用 `--max-events N` 或 `--duration 10m`。
- 订阅清理:本次新建的订阅任意干净退出即自动退订;`--subscribe-id` 复用的保留,`--ephemeral` 强制退订。优雅停用 SIGTERM、关 stdin,或外部先用 `dws event stop <subscribe_id> --dry-run` 预览、确认后加 `--yes`。不要 `kill -9`(跳过退订、泄漏服务端订阅)。
- 批量清理先用 `dws event stop --all --dry-run` 预览,确认后加 `--yes`。
@@ -79,7 +92,12 @@ dws event consume user_im_message_receive_at -f ndjson
# 当前用户与指定用户的单聊消息
dws event consume user_im_message_receive_o2o \
--user 507971 \
--user test-user-001 \
-f ndjson
# 使用 openDingtalkId 监听外部联系人、机器人或跨组织身份的单聊消息
dws event consume user_im_message_receive_o2o \
--open-dingtalk-id open-user-1 \
-f ndjson
# 指定群聊/会话消息
@@ -87,6 +105,31 @@ dws event consume user_im_message_receive_group \
--group cidxxxxxxxx \
-f ndjson
# 指定发送人的消息(单聊和群聊)
dws event consume user_im_message_receive_user \
--user test-user-001 \
-f ndjson
# 使用 openDingtalkId 监听指定发送人的消息
dws event consume user_im_message_receive_user \
--open-dingtalk-id open-user-1 \
-f ndjson
# 指定单聊消息已读
dws event consume user_im_message_read_o2o \
--user test-user-001 \
-f ndjson
# 指定群聊消息撤回
dws event consume user_im_message_recall_group \
--group cidxxxxxxxx \
-f ndjson
# 指定单聊消息收到表情回应
dws event consume user_im_message_reaction_o2o \
--user test-user-001 \
-f ndjson
# 有界自测
dws event consume user_im_message_receive_at \
--duration 10m \
@@ -94,20 +137,26 @@ dws event consume user_im_message_receive_at \
# 抓一条样本
dws event consume user_im_message_receive_o2o \
--user 507971 \
--user test-user-001 \
--max-events 1 \
-f json
```
所有 `*_o2o` 命令和 `user_im_message_receive_user` 都可将 `--user <userId>` 替换为 `--open-dingtalk-id <openDingtalkId>`,但两个参数不能同时使用。
## 输出处理
- `dws event schema <event_key>` 是写解析逻辑的依据。
- 顶层 `jq_root_path` 说明业务字段起点;当前值是 `.data | fromjson`。
- 顶层 `jq_root_path` 说明业务字段起点;当前值是 `.`。
- `schema.properties` 是业务字段列表,例如 `content`、`sender`、`conversation_id`、`message_id`、`event_time`。
- 不要假设 `data` 已经展开。读取消息正文时先解析 `data`,再读 `payload.body.content` 或 schema 中对应字段。
- 所有公开事件都是扁平业务对象,直接读取顶层字段;不要生成 `fromjson` 或内部 payload 路径。
- 群自动回复使用事件顶层 `conversation_id`;单聊自动回复使用顶层 `sender_open_dingtalk_id`。
- 已读事件读取顶层 `reader`、`reader_open_dingtalk_id`、`read_time`;撤回事件读取 `recaller`、`recaller_open_dingtalk_id`、`recall_time`。
- 表情回应事件读取顶层 `operator`、`operator_open_dingtalk_id`、`reaction_name`、`reaction_text`、`operation_type`、`operation_time`。
- 图片、文件等媒体消息的 `content` 可能是可读描述;需要实际媒体文件时调用 `dws chat message download-media`。
## Topic index
| Topic | Reference | Coverage |
|---|---|---|
| IM | [references/event-im.md](references/event-im.md) | 三类个人消息事件命令、参数、生命周期、输出解析、自测、过滤和排障 |
| IM | [references/event-im.md](references/event-im.md) | 十类个人 IM 事件命令、参数、生命周期、输出解析、自测和排障 |
@@ -1,6 +1,6 @@
# IM 个人消息事件
# IM 个人事件
先读上层 [SKILL.md](../SKILL.md) 的命令规则、调用流和子进程契约。本参考覆盖当前公开的 IM 个人消息事件:被 @、指定单聊、指定群聊。
先读上层 [SKILL.md](../SKILL.md) 的命令规则、调用流和子进程契约。本参考覆盖当前公开的 IM 个人事件:消息接收、已读、撤回和表情回应。
实时监听、自动回复、订阅事件都必须使用 `dws event consume` 长连接,不要写轮询脚本。
@@ -18,26 +18,45 @@ dws auth login
dws event schema user_im_message_receive_at
dws event schema user_im_message_receive_o2o
dws event schema user_im_message_receive_group
dws event schema user_im_message_receive_user
dws event schema user_im_message_read_o2o
dws event schema user_im_message_read_group
dws event schema user_im_message_recall_o2o
dws event schema user_im_message_recall_group
dws event schema user_im_message_reaction_o2o
dws event schema user_im_message_reaction_group
```
schema 默认 JSON。业务字段说明在 `schema.properties`,当前业务 payload 解析起点看 `jq_root_path`。
schema 默认 JSON。业务字段说明在 `schema.properties`,`jq_root_path` 当前固定为 `.`。
## Event catalog
| 事件码 | 规则 | 用途 | 必填参数 |
|---|---|---|---|
| `user_im_message_receive_at` | `at` | 当前用户被 @ 的消息 | 无 |
| `user_im_message_receive_o2o` | `singleChat` | 当前用户与指定用户的单聊消息 | `--user` |
| `user_im_message_receive_o2o` | `singleChat` | 当前用户与指定用户的单聊消息 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_receive_group` | `group` | 当前用户所在指定群聊/会话的消息 | `--group` |
| `user_im_message_receive_user` | `sender` | 当前用户收到的指定用户发送的消息(单聊和群聊) | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_read_o2o` | `singleChat` | 指定单聊中当前用户发送的消息被已读 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_read_group` | `group` | 指定群聊中当前用户发送的消息被已读 | `--group` |
| `user_im_message_recall_o2o` | `singleChat` | 指定单聊中的消息被撤回 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_recall_group` | `group` | 指定群聊中的消息被撤回 | `--group` |
| `user_im_message_reaction_o2o` | `singleChat` | 指定单聊中的消息收到表情回应 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_reaction_group` | `group` | 指定群聊中的消息收到表情回应 | `--group` |
默认身份就是当前用户。不要额外加身份切换 flag,不要使用应用凭证模式,不要使用本表以外的事件码。
## ID resolution
- 人名 → `dws aisearch person --keyword "<name>" --dimension name --format json`,确认后取 `userId`。
- 企业内部 userId → `--user`;明确给出 openDingtalkId,或目标是外部联系人、机器人、跨组织身份 → `--open-dingtalk-id`。
- 两个身份参数严格二选一,不得把 openDingtalkId 放进 `--user`,也不要自动猜测或转换;缺少外部目标的 openDingtalkId 时先追问。
- “我和某人的单聊”选择 `user_im_message_receive_o2o`;“某人发给我的消息/某人发送的消息”选择 `user_im_message_receive_user`。
- 群名 → `dws chat search --query "<group>" --format json`,确认后取 `openConversationId`。
- 多候选 → 展示候选并让用户确认。
- 仍缺必填 ID → 先追问,不要编造。
- “撤回消息”表示执行操作时走 `dws chat`;“监听/订阅消息撤回”才走本事件能力。
- “贴标签”表示给消息贴表情时,对应 `reaction` 表情回应事件。
## Consume commands
@@ -47,13 +66,58 @@ dws event consume user_im_message_receive_at -f ndjson
# 指定单聊消息
dws event consume user_im_message_receive_o2o \
--user 507971 \
--user test-user-001 \
-f ndjson
# 通过 openDingtalkId 指定单聊对端
dws event consume user_im_message_receive_o2o \
--open-dingtalk-id open-user-1 \
-f ndjson
# 指定群消息
dws event consume user_im_message_receive_group \
--group cidxxxxxxxx \
-f ndjson
# 指定发送人的消息(单聊和群聊)
dws event consume user_im_message_receive_user \
--user test-user-001 \
-f ndjson
# 通过 openDingtalkId 指定发送人
dws event consume user_im_message_receive_user \
--open-dingtalk-id open-user-1 \
-f ndjson
# 指定单聊已读事件
dws event consume user_im_message_read_o2o \
--user test-user-001 \
-f ndjson
# 指定群聊已读事件
dws event consume user_im_message_read_group \
--group cidxxxxxxxx \
-f ndjson
# 指定单聊撤回事件
dws event consume user_im_message_recall_o2o \
--user test-user-001 \
-f ndjson
# 指定群聊撤回事件
dws event consume user_im_message_recall_group \
--group cidxxxxxxxx \
-f ndjson
# 指定单聊表情回应事件
dws event consume user_im_message_reaction_o2o \
--user test-user-001 \
-f ndjson
# 指定群聊表情回应事件
dws event consume user_im_message_reaction_group \
--group cidxxxxxxxx \
-f ndjson
```
## Self-test triggers
@@ -61,10 +125,17 @@ dws event consume user_im_message_receive_group \
| 事件码 | 自测参数 | 触发方式 |
|---|---|---|
| `user_im_message_receive_at` | `--duration 10m -f ndjson` | 让任意可触达用户在群里 @ 当前登录用户 |
| `user_im_message_receive_o2o` | `--user <userId> --duration 10m -f ndjson` | 让对端用户给当前登录用户发送单聊消息 |
| `user_im_message_receive_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--duration 10m -f ndjson` | 让对端用户给当前登录用户发送单聊消息 |
| `user_im_message_receive_group` | `--group <openConversationId> --duration 10m -f ndjson` | 让任意用户在该群发送消息 |
| `user_im_message_receive_user` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--duration 10m -f ndjson` | 让指定用户分别在单聊或共同群聊中发送消息 |
| `user_im_message_read_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--duration 10m -f ndjson` | 当前用户给对端发送单聊消息,再让对端打开并阅读 |
| `user_im_message_read_group` | `--group <openConversationId> --duration 10m -f ndjson` | 当前用户在群内发送消息,再让群成员打开并阅读 |
| `user_im_message_recall_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--duration 10m -f ndjson` | 在指定单聊中发送并撤回一条消息 |
| `user_im_message_recall_group` | `--group <openConversationId> --duration 10m -f ndjson` | 在指定群聊中发送并撤回一条消息 |
| `user_im_message_reaction_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--duration 10m -f ndjson` | 在指定单聊中给消息添加表情回应 |
| `user_im_message_reaction_group` | `--group <openConversationId> --duration 10m -f ndjson` | 在指定群聊中给消息添加表情回应 |
stderr 出现固定就绪行 `[event] ready event_key=<key> bus_pid=<pid>` 表示本地 consume 已连接到事件 bus;父进程等这行再读 stdout。stdout 每行是一个事件 JSON。
stderr 出现固定就绪行 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>` 表示本地 consume 已连接到事件 bus;父进程等这行再读 stdout。stdout 每行是一个扁平事件 JSON。
## Runtime flags
@@ -77,6 +148,7 @@ stderr 出现固定就绪行 `[event] ready event_key=<key> bus_pid=<pid>` 表
| `--output-dir <dir>` | 每个事件写入一个文件 |
| `--route '<regex>=dir:<path>'` | 按事件类型路由到目录 |
| `--subscribe-id <id>` | 复用已有个人订阅 |
| `--ephemeral` | 即使复用已有订阅,也在 consume 退出时取消订阅 |
| `--query <csv>` | 按消息正文关键词过滤,逗号分隔 |
| `--filter-json <json>` | 使用个人事件 Filter DSL 过滤 |
| `--debug-raw-events` | 联调用:绕过本地过滤,输出当前 personal stream 实际收到的可解析事件 |
@@ -85,30 +157,39 @@ stderr 出现固定就绪行 `[event] ready event_key=<key> bus_pid=<pid>` 表
## Output parsing
`-f ndjson` 的 stdout 每行是一个外层事件对象,常见顶层字段:
`-f ndjson` 的 stdout 每行就是一个扁平业务事件对象。消息接收事件常见顶层字段:
| 字段 | 说明 |
|---|---|
| `event_type` | 个人事件码 |
| `event_id` | 本地输出事件 ID |
| `type` | 个人事件码 |
| `event_id` | 事件 ID,可用于去重 |
| `timestamp` | 事件发生时间戳 |
| `subscribe_id` | 个人订阅 ID,也是本地输出隔离键 |
| `source_id` | 当前 sourceId |
| `data` | 服务端业务 payload 的 JSON 字符串 |
| `received_at_unix_ms` | 本地接收时间 |
| `content` | 消息正文 |
| `sender` | 发送人展示名 |
| `conversation_id` | 开放会话 ID |
| `message_id` | 开放消息 ID |
| `sender_open_dingtalk_id` | 发送人的开放钉钉 ID |
| `create_time` | 消息创建时间 |
| `event_time` | 消息事件时间戳 |
读取业务字段前先运行 `dws event schema <event_key>`。当前 schema 顶层的 `jq_root_path` 是 `.data | fromjson`,表示先把 `data` 再解析一次,然后读取业务字段。
直接按顶层字段解析,不要使用 `fromjson`,也不要依赖内部 transport payload 路径。图片、文件等媒体消息的 `content` 可能是可读描述;需要实际媒体文件时调用 `dws chat message download-media`。
解析后的常用业务字段:
所有动作事件都包含顶层 `type`、`event_id`、`timestamp`、`subscribe_id`、`message_id`、`conversation_id`、`sender`、`sender_open_dingtalk_id` 和 `event_time`。各类动作的专有字段如下:
| schema 字段 | 当前真实路径 | 说明 |
|---|---|---|
| `content` | `payload.body.content` | 消息正文 |
| `sender` | `payload.body.sender` | 发送人展示名 |
| `conversation_id` | `payload.body.openConversationId` | 开放会话 ID |
| `message_id` | `payload.body.openMessageId` | 开放消息 ID |
| `sender_open_dingtalk_id` | `payload.body.senderOpenDingTalkId` | 发送人的开放钉钉 ID |
| `create_time` | `payload.body.createTime` | 消息创建时间 |
| `event_time` | `payload.event_time` | 消息事件时间戳 |
| 事件类型 | 顶层业务字段 |
|---|---|
| 已读 | `reader`、`reader_open_dingtalk_id`、`read_time` |
| 撤回 | `recaller`、`recaller_open_dingtalk_id`、`recall_time` |
| 表情回应 | `operator`、`operator_open_dingtalk_id`、`reaction_name`、`reaction_text`、`operation_type`、`operation_time` |
正常输出不会暴露 `payload`、`uid`、`corpid`、`clientId`、`filterSubId`、`bizid` 等内部字段。需要检查原始协议时才使用 `-f raw` 或 `--debug-raw-events`。
## Event-driven replies
- 群消息自动回复:读取顶层 `conversation_id`,再调用 `dws chat message send --group "<conversation_id>" --text "<reply>" --format json`。
- 单聊自动回复:读取顶层 `sender_open_dingtalk_id`,再调用 `dws chat message send --open-dingtalk-id "<sender_open_dingtalk_id>" --text "<reply>" --format json`。
- 正常处理持续读取 consume 的 stdout 管道。不要用 `sleep` 猜测建联,不要改写成 `--output-dir` watcher。
## Filtering
@@ -117,7 +198,7 @@ stderr 出现固定就绪行 `[event] ready event_key=<key> bus_pid=<pid>` 表
- 单聊用 `--user`。
- 群消息用 `--group`。
额外文本过滤再用 `--query` 或 `--filter-json`:
收消息事件需要额外文本过滤时再用 `--query` 或 `--filter-json`:
```bash
dws event consume user_im_message_receive_group \
@@ -126,7 +207,9 @@ dws event consume user_im_message_receive_group \
-f ndjson
```
`--filter-json` 可以使用业务别名,也可以使用真实路径。优先使用 schema 字段名表达意图;需要和服务端联调时再使用真实路径。
`--filter-json` 使用 `content`、`sender`、`conversation_id`、`sender_open_dingtalk_id` 等业务别名表达意图。
动作事件只使用 `--user` 或 `--group` 限定订阅范围。`--query` 和消息内容 `--filter-json` 面向接收消息事件,不用于已读、撤回或表情回应事件。
## Status and stop
@@ -134,6 +217,10 @@ dws event consume user_im_message_receive_group \
dws event status --event user_im_message_receive_at
dws event status --event user_im_message_receive_o2o
dws event status --event user_im_message_receive_group
dws event status --event user_im_message_receive_user
dws event status --event user_im_message_read_o2o
dws event status --event user_im_message_recall_group
dws event status --event user_im_message_reaction_o2o
```
`status` 同时展示服务端 `Subscriptions` 和本地 `Consumers`。`Consumers` 表里的 PID、事件码、`subscribe_id`、received/dropped 计数用于确认当前前台 consume 是否还挂在 personal bus 上。
@@ -152,12 +239,12 @@ dws event stop --all --dry-run
dws event stop --all --yes
```
裸 `dws event stop` 不会取消订阅。前台 `consume` 进程用 Ctrl+C 只会停止本地进程;需要取消服务端订阅时,使用事件输出或 `status` 里的 `subscribe_id`,先执行 `dws event stop <subscribe_id> --dry-run`,确认预览后再加 `--yes`。
裸 `dws event stop` 不会取消订阅。本次 consume 新建的订阅会在 SIGTERM、Ctrl+C、stdin EOF、duration 或 max-events 等干净退出时自动取消;通过 `--subscribe-id` 复用的订阅默认保留。需要从外部取消时,使用事件输出或 `status` 里的 `subscribe_id`,先执行 `dws event stop <subscribe_id> --dry-run`,确认预览后再加 `--yes`。不要使用 `kill -9`,它会跳过清理。
## Troubleshooting
- 没有输出:先确认 stderr 已出现 `[event] ready event_key=...`。
- 参数缺失:单聊必须有对端 ID,群消息必须有 openConversationId。
- 没有输出:先确认 stderr 已出现 `[event] ready event_key=... bus_pid=... subscribe_id=...`。
- 参数缺失:所有 o2o 事件必须有对端 ID,所有 group 事件必须有 openConversationId。
- 收到非预期消息:检查 stdout 的 `subscribe_id` 是否等于当前命令创建/复用的订阅 ID。
- 需要判断服务端是否推到当前连接:临时加 `--debug --debug-raw-events`,排查后去掉。
- 需要长期运行:交给外部进程管理;不要把消息历史查询写成轮询脚本。
+66 -82
View File
@@ -17,21 +17,36 @@ var expectedHomeSkillTargets = []string{
".cursor/skills/dws",
}
func TestInstallScriptSourceModeInstallsBinary(t *testing.T) {
t.Parallel()
type installSourceFixture struct {
root string
scriptPath string
stubRoot string
fakeHome string
}
root := t.TempDir()
installDir := filepath.Join(root, "bin")
func newInstallSourceFixture(t *testing.T) *installSourceFixture {
t.Helper()
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.sh"))
repoScript, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.sh"))
if err != nil {
t.Fatalf("Abs(install.sh) error = %v", err)
}
scriptData, err := os.ReadFile(repoScript)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", repoScript, err)
}
root := t.TempDir()
scriptPath := filepath.Join(root, "scripts", "install.sh")
mustWriteFile(t, scriptPath, scriptData, 0o755)
// resolve_source_root requires both go.mod and cmd/. The source installer
// tests need only that layout plus small representative skill trees.
mustWriteFile(t, filepath.Join(root, "go.mod"), []byte("module example.com/dws-install-test\n"), 0o644)
mustWriteFile(t, filepath.Join(root, "cmd", ".keep"), nil, 0o644)
mustWriteFile(t, filepath.Join(root, "skills", "mono", "SKILL.md"), []byte("# Test skill\n"), 0o644)
mustWriteFile(t, filepath.Join(root, "skills", "multi", "dingtalk-test", "SKILL.md"), []byte("# Test split skill\n"), 0o644)
// Stub make: when invoked as "make -C <dir> build", create a fake dws binary
// in the project root (the -C target directory).
stubRoot := filepath.Join(root, "stubs")
repoRoot, _ := filepath.Abs(filepath.Join("..", ".."))
makeStub := `#!/bin/sh
set -eu
dir=""
@@ -44,27 +59,48 @@ done
[ -n "$dir" ] && printf 'fake-binary\n' > "$dir/dws"
`
mustWriteFile(t, filepath.Join(stubRoot, "make"), []byte(makeStub), 0o755)
// Also need a stub go so need_cmd check passes
mustWriteFile(t, filepath.Join(stubRoot, "go"), []byte("#!/bin/sh\ntrue\n"), 0o755)
cmd := exec.Command("sh", scriptPath)
cmd.Env = append(os.Environ(),
"PATH="+stubRoot+":"+os.Getenv("PATH"),
return &installSourceFixture{
root: root,
scriptPath: scriptPath,
stubRoot: stubRoot,
fakeHome: filepath.Join(root, "home"),
}
}
func (f *installSourceFixture) env(extra ...string) []string {
env := append(os.Environ(),
"HOME="+f.fakeHome,
"PATH="+f.stubRoot+":"+os.Getenv("PATH"),
"DWS_VERSION=latest",
"DWS_SKILLS_ONLY=0",
"DWS_SKILL_MODE=mono",
)
return append(env, extra...)
}
func TestInstallScriptSourceModeInstallsBinary(t *testing.T) {
t.Parallel()
fixture := newInstallSourceFixture(t)
installDir := filepath.Join(fixture.root, "bin")
cmd := exec.Command("sh", fixture.scriptPath)
cmd.Env = fixture.env(
"DWS_INSTALL_DIR="+installDir,
"DWS_INSTALL_NAME=dws-test",
"DWS_NO_SKILLS=1",
)
output, err := cmd.CombinedOutput()
// Clean up the fake binary created in the real repo root
_ = os.Remove(filepath.Join(repoRoot, "dws"))
if err != nil {
t.Fatalf("install.sh error = %v\noutput:\n%s", err, string(output))
}
got := string(output)
for _, want := range []string{
"Installing dws from source checkout",
"Installing dws from source checkout: " + fixture.root,
"Install dir: " + installDir,
"Binary installed:",
installDir + "/dws-test",
@@ -87,53 +123,27 @@ done
func TestInstallScriptSourceModeInstallsSkillsIntoAgentsDir(t *testing.T) {
t.Parallel()
root := t.TempDir()
fakeHome := filepath.Join(root, "home")
installDir := filepath.Join(root, "bin")
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.sh"))
if err != nil {
t.Fatalf("Abs(install.sh) error = %v", err)
}
stubRoot := filepath.Join(root, "stubs")
repoRoot, _ := filepath.Abs(filepath.Join("..", ".."))
makeStub := `#!/bin/sh
set -eu
dir=""
while [ "$#" -gt 0 ]; do
case "$1" in
-C) dir="$2"; shift 2 ;;
*) shift ;;
esac
done
[ -n "$dir" ] && printf 'fake-binary\n' > "$dir/dws"
`
mustWriteFile(t, filepath.Join(stubRoot, "make"), []byte(makeStub), 0o755)
mustWriteFile(t, filepath.Join(stubRoot, "go"), []byte("#!/bin/sh\ntrue\n"), 0o755)
fixture := newInstallSourceFixture(t)
installDir := filepath.Join(fixture.root, "bin")
// Gate for index>0 agent dirs (matches build/npm/install.js): parent must exist.
if err := os.MkdirAll(filepath.Join(fakeHome, ".cursor"), 0o755); err != nil {
if err := os.MkdirAll(filepath.Join(fixture.fakeHome, ".cursor"), 0o755); err != nil {
t.Fatalf("MkdirAll(.cursor) error = %v", err)
}
cmd := exec.Command("sh", scriptPath)
cmd.Env = append(os.Environ(),
"HOME="+fakeHome,
"PATH="+stubRoot+":"+os.Getenv("PATH"),
cmd := exec.Command("sh", fixture.scriptPath)
cmd.Env = fixture.env(
"DWS_INSTALL_DIR="+installDir,
"DWS_NO_SKILLS=0",
)
output, err := cmd.CombinedOutput()
// Clean up the fake binary created in the real repo root
_ = os.Remove(filepath.Join(repoRoot, "dws"))
if err != nil {
t.Fatalf("install.sh error = %v\noutput:\n%s", err, string(output))
}
for _, rel := range expectedHomeSkillTargets {
skillPath := filepath.Join(fakeHome, filepath.FromSlash(rel), "SKILL.md")
skillPath := filepath.Join(fixture.fakeHome, filepath.FromSlash(rel), "SKILL.md")
if _, err := os.Stat(skillPath); err != nil {
t.Fatalf("Stat(%s) error = %v\noutput:\n%s", skillPath, err, string(output))
}
@@ -694,48 +704,22 @@ func TestInstallScriptsCacheMultiSkills(t *testing.T) {
func TestInstallScriptCachesMultiEndToEnd(t *testing.T) {
t.Parallel()
root := t.TempDir()
fakeHome := filepath.Join(root, "home")
installDir := filepath.Join(root, "bin")
fixture := newInstallSourceFixture(t)
installDir := filepath.Join(fixture.root, "bin")
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.sh"))
if err != nil {
t.Fatalf("Abs(install.sh) error = %v", err)
}
stubRoot := filepath.Join(root, "stubs")
repoRoot, _ := filepath.Abs(filepath.Join("..", ".."))
makeStub := `#!/bin/sh
set -eu
dir=""
while [ "$#" -gt 0 ]; do
case "$1" in
-C) dir="$2"; shift 2 ;;
*) shift ;;
esac
done
[ -n "$dir" ] && printf 'fake-binary\n' > "$dir/dws"
`
mustWriteFile(t, filepath.Join(stubRoot, "make"), []byte(makeStub), 0o755)
mustWriteFile(t, filepath.Join(stubRoot, "go"), []byte("#!/bin/sh\ntrue\n"), 0o755)
cmd := exec.Command("sh", scriptPath)
cmd.Env = append(os.Environ(),
"HOME="+fakeHome,
"PATH="+stubRoot+":"+os.Getenv("PATH"),
cmd := exec.Command("sh", fixture.scriptPath)
cmd.Env = fixture.env(
"DWS_INSTALL_DIR="+installDir,
"DWS_NO_SKILLS=0",
)
output, err := cmd.CombinedOutput()
// Clean up the fake binary created in the real repo root
_ = os.Remove(filepath.Join(repoRoot, "dws"))
if err != nil {
t.Fatalf("install.sh error = %v\noutput:\n%s", err, string(output))
}
// Verify multi cache was populated. We expect dingtalk-* subdirs.
multiCache := filepath.Join(fakeHome, ".dws", "skills", "multi")
multiCache := filepath.Join(fixture.fakeHome, ".dws", "skills", "multi")
entries, err := os.ReadDir(multiCache)
if err != nil {
t.Fatalf("ReadDir(%s) error = %v\noutput:\n%s", multiCache, err, string(output))
@@ -751,7 +735,7 @@ done
}
// And verify mono cache.
monoCacheSkill := filepath.Join(fakeHome, ".dws", "skills", "mono", "SKILL.md")
monoCacheSkill := filepath.Join(fixture.fakeHome, ".dws", "skills", "mono", "SKILL.md")
if _, err := os.Stat(monoCacheSkill); err != nil {
t.Fatalf("missing mono cache SKILL.md at %s: %v", monoCacheSkill, err)
}
+513 -5
View File
@@ -598,6 +598,503 @@ func TestPostGoreleaserSkillsZipLayout(t *testing.T) {
}
}
func readReleaseWorkflow(t *testing.T) string {
t.Helper()
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml"))
if err != nil {
t.Fatalf("Abs(release.yml) error = %v", err)
}
data, err := os.ReadFile(workflowPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", workflowPath, err)
}
return string(data)
}
func releaseWorkflowSection(t *testing.T, workflow, startMarker, endMarker string) string {
t.Helper()
start := strings.Index(workflow, startMarker)
if start == -1 {
t.Fatalf("release workflow is missing section marker %q", startMarker)
}
end := strings.Index(workflow[start+len(startMarker):], endMarker)
if end == -1 {
t.Fatalf("release workflow section %q is missing end marker %q", startMarker, endMarker)
}
return workflow[start : start+len(startMarker)+end]
}
func TestReleaseWorkflowUsesDedicatedGovernanceIdentity(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
const (
checksCall = "github.rest.checks.listForRef"
immutableCall = `"GET /repos/{owner}/{repo}/immutable-releases"`
governanceID = `github-token: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}`
)
if got := strings.Count(workflow, checksCall); got != 2 {
t.Fatalf("release workflow Checks API call count = %d, want one tag check and one preflight check", got)
}
if got := strings.Count(workflow, immutableCall); got != 2 {
t.Fatalf("release workflow immutable governance call count = %d, want one tag check and one preflight check", got)
}
if got := strings.Count(workflow, governanceID); got != 2 {
t.Fatalf("release workflow dedicated governance identity count = %d, want one per immutable check", got)
}
sections := map[string]string{
"preflight": releaseWorkflowSection(t, workflow, " governance-preflight:\n", "\n release-contract:\n"),
"tag": releaseWorkflowSection(t, workflow, " release-contract:\n", "\n release:\n"),
}
for name, section := range sections {
for _, required := range []string{
"checks: read",
checksCall,
immutableCall,
governanceID,
"RELEASE_GOVERNANCE_TOKEN with repository Administration read permission is required",
} {
if !strings.Contains(section, required) {
t.Errorf("%s governance path is missing %q", name, required)
}
}
if strings.Contains(section, "contents: write") {
t.Errorf("%s governance path must not grant contents write permission", name)
}
if strings.Contains(section, `github-token: ${{ secrets.GITHUB_TOKEN }}`) {
t.Errorf("%s immutable governance path must not fall back to GITHUB_TOKEN", name)
}
}
}
func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
preflight := releaseWorkflowSection(t, workflow, " governance-preflight:\n", "\n release-contract:\n")
for _, required := range []string{
"governance_preflight_commit:",
"governance_preflight_nonce:",
`format('Release governance preflight {0}', inputs.governance_preflight_nonce)`,
"name: Release governance preflight",
"name: Check out trusted preflight tooling",
"github.event_name == 'workflow_dispatch'",
"EXPECTED_REPOSITORY: DingTalk-Real-AI/dingtalk-workspace-cli",
`DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}`,
`test "$PREFLIGHT_COMMIT" = "$GITHUB_SHA"`,
`ref: ${{ inputs.governance_preflight_commit }}`,
"persist-credentials: false",
"governance preflight cannot be combined with npm repair",
} {
if !strings.Contains(workflow, required) {
t.Errorf("governance preflight contract is missing %q", required)
}
}
for _, forbidden := range []string{
"contents: write",
"goreleaser",
"gh release",
"npm publish",
"sync-to-oss",
"sync-to-gitee",
} {
if strings.Contains(preflight, forbidden) {
t.Errorf("governance preflight must not contain publishing behavior %q", forbidden)
}
}
ciGate := strings.Index(preflight, "Require successful CI Gate on the preflight commit")
homebrewCanary := strings.Index(preflight, "Verify Homebrew PR automation permission")
if ciGate == -1 || homebrewCanary == -1 || ciGate > homebrewCanary {
t.Error("governance preflight must validate the exact CI Gate before exposing Homebrew credentials")
}
mirror := releaseWorkflowSection(t, workflow, " mirror-gitee-release:\n", "\n repair-npm:\n")
if !strings.Contains(mirror, "needs: [release-contract, release, publish-channels]") {
t.Error("Gitee publication must remain downstream of the verified release target and channel jobs")
}
repair := workflow[strings.Index(workflow, " repair-npm:\n"):]
for _, required := range []string{
"needs: dispatch-contract",
"needs.dispatch-contract.outputs.mode == 'repair_npm'",
} {
if !strings.Contains(repair, required) {
t.Errorf("npm repair dispatch contract is missing %q", required)
}
}
}
func TestReleaseWorkflowGiteeRepairUsesSealedReleaseAuthority(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
dispatch := releaseWorkflowSection(t, workflow, " dispatch-contract:\n", "\n authorize-recovery:\n")
start := strings.Index(workflow, " repair-gitee:\n")
if start == -1 {
t.Fatal("release workflow is missing the Gitee repair job")
}
repair := workflow[start:]
for _, required := range []string{
"repair_gitee_version:",
`format('Release Gitee repair {0}', inputs.repair_gitee_version)`,
`REPAIR_GITEE_VERSION: ${{ inputs.repair_gitee_version }}`,
"gitee_repair=0",
`test -z "$REPAIR_GITEE_VERSION" || gitee_repair=1`,
"npm_repair + gitee_repair + governance + recovery",
`echo "mode=repair_gitee" >> "$GITHUB_OUTPUT"`,
} {
if !strings.Contains(dispatch, required) && !strings.Contains(workflow, required) {
t.Errorf("Gitee repair dispatch contract is missing %q", required)
}
}
for _, required := range []string{
"needs: dispatch-contract",
`if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'repair_gitee'`,
`github.ref == format('refs/heads/{0}', github.event.repository.default_branch)`,
`github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'`,
"actions: read",
`ref: ${{ github.sha }}`,
"path: tooling",
"persist-credentials: false",
"release_is_stable_version",
"release_is_prerelease_version",
`ref: ` + "`tags/${version}`",
`["ahead", "identical"].includes(comparison.data.status)`,
"!release.data.immutable",
`release.data.prerelease !== expectedPrerelease`,
"assetNames.length !== expectedAssets.size",
"new Set(assetNames).size !== expectedAssets.size",
`core.setOutput("tag_object", ref.data.object.sha)`,
"verify-github-tag-authority.sh",
"verify-release-workflow-delivery.sh",
`DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}`,
"verify-release-artifacts.sh",
`--repo "$GITHUB_REPOSITORY"`,
`DWS_REQUIRE_GITEE: "1"`,
"working-directory: tooling\n run: |\n " +
`"$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-gitee.sh"`,
} {
if !strings.Contains(repair, required) {
t.Errorf("Gitee repair authority is missing %q", required)
}
}
for _, asset := range []string{
"dws-darwin-amd64.tar.gz",
"dws-darwin-arm64.tar.gz",
"dws-linux-amd64.tar.gz",
"dws-linux-arm64.tar.gz",
"dws-windows-amd64.zip",
"dws-windows-arm64.zip",
"dws-skills.zip",
"checksums.txt",
} {
if strings.Count(repair, `"`+asset+`"`) != 1 {
t.Errorf("Gitee repair must require exactly one %s asset declaration", asset)
}
}
if strings.Contains(repair, "contents: write") {
t.Error("Gitee repair must not grant contents write permission")
}
if strings.Contains(repair, "ref: ${{ github.event.repository.default_branch }}") {
t.Error("Gitee repair must not check out a floating default branch")
}
if got := strings.Count(repair, "working-directory: tooling"); got < 4 {
t.Errorf("Gitee repair trusted tooling working-directory count = %d, want at least 4", got)
}
}
func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
for _, required := range []string{
"recover_release_version:",
"recover_release_tag_object:",
"recover_release_commit:",
"recover_failed_run_id:",
"recover_failed_run_attempt:",
"recover_release_nonce:",
"recover_release_confirmation:",
`format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce)`,
"workflow_dispatch must select exactly one release mode",
"release recovery confirmation must equal the exact version",
"recover_release_nonce must be bound to the release commit",
"environment: release-recovery",
"prevent_self_review !== true",
"protected_branches !== true",
"can_admins_bypass !== false",
`run.path !== ".github/workflows/release.yml"`,
`run.event !== "push"`,
`"GET /repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number}"`,
"attempt_number: Number(failedRunAttempt)",
"run.run_attempt !== Number(failedRunAttempt)",
`["failure", "cancelled", "timed_out", "startup_failure", "stale"].includes(run.conclusion)`,
`run.head_branch !== version`,
`run.head_sha !== commit`,
`tagObject !== expectedTagObject`,
`["ahead", "identical"].includes(comparison.data.status)`,
"already has a public release; use a channel repair instead",
"Bind recovery publication to this workflow run",
"dws-release-recovery run=%s tag-object=%s commit=%s",
"Public release is not bound to this exact recovery run.",
"Public recovery asset differs from this run's sealed artifact",
`ref: process.env.RELEASE_COMMIT`,
`path: tmp/trusted-release-tooling`,
`ref: ${{ github.sha }}`,
`step.name === "Require immutable published GitHub Release"`,
"Require a clean sealed source before GoReleaser",
`git status --porcelain --untracked-files=all`,
} {
if !strings.Contains(workflow, required) {
t.Errorf("release recovery contract is missing %q", required)
}
}
sections := map[string]string{
"release": releaseWorkflowSection(t, workflow, " release:\n", "\n verify-darwin-signatures:\n"),
"publish-release": releaseWorkflowSection(t, workflow, " publish-release:\n", "\n publish-channels:\n"),
"publish-channels": releaseWorkflowSection(t, workflow, " publish-channels:\n", "\n mirror-gitee-release:\n"),
}
for name, section := range sections {
for _, required := range []string{
"needs.release-contract.outputs.release_version",
"ref: ${{ needs.release-contract.outputs.release_commit }}",
"persist-credentials: false",
`tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh`,
} {
if !strings.Contains(section, required) {
t.Errorf("%s does not consume the verified recovery target %q", name, required)
}
}
if strings.Contains(section, "github.event_name == 'workflow_dispatch'") {
t.Errorf("%s must not fork into a recovery-specific publisher", name)
}
}
if strings.Count(workflow, "name: Build signed release artifacts") != 1 ||
strings.Count(workflow, "name: Verify Apple Developer ID signatures") != 1 ||
strings.Count(workflow, "name: Publish immutable GitHub Release") != 1 ||
strings.Count(workflow, "name: Publish npm and mirrors") != 1 {
t.Fatal("normal and recovery publication must share one build/sign/publish job graph")
}
}
func TestReleaseWorkflowDraftLifecycleUsesOneReleaseID(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
publishJob := releaseWorkflowSection(t, workflow, " publish-release:\n", "\n publish-channels:\n")
publishStep := releaseWorkflowSection(
t,
publishJob,
" - name: Publish or reuse immutable GitHub Release\n",
"\n - name: Require immutable published GitHub Release\n",
)
for _, required := range []string{
"id: publish",
"--json databaseId",
`"repos/$GITHUB_REPOSITORY/releases/$release_id"`,
`uploaded_release_id="$(`,
`test "$uploaded_release_id" = "$release_id"`,
"Draft GitHub Release ID $release_id targets",
"Draft GitHub Release notes differ from the sealed CHANGELOG.",
"Draft GitHub Release is not bound to this exact recovery run.",
`DWS_GITHUB_RELEASE_ID="$release_id"`,
`tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh`,
`tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh`,
`cmp -s "$local_asset" "$remote_asset"`,
"-F draft=false",
`echo "release_id=$release_id" >> "$GITHUB_OUTPUT"`,
} {
if !strings.Contains(publishStep, required) {
t.Errorf("Draft release lifecycle is missing %q", required)
}
}
for _, forbidden := range []string{
`gh release download "$RELEASE_VERSION"`,
`gh release edit "$RELEASE_VERSION" --draft=false`,
`"repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_VERSION"`,
} {
if strings.Contains(publishStep, forbidden) {
t.Errorf("Draft release lifecycle must not switch back from the locked release ID via %q", forbidden)
}
}
tagGuard := strings.Index(publishStep, "Draft GitHub Release ID $release_id targets")
draftPatch := strings.Index(publishStep, "-F draft=true")
bodyVerify := strings.Index(publishStep, "Draft GitHub Release notes differ from the sealed CHANGELOG.")
markerVerify := strings.Index(publishStep, "Draft GitHub Release is not bound to this exact recovery run.")
upload := strings.Index(publishStep, `gh release upload "$RELEASE_VERSION"`)
idRecheck := strings.Index(publishStep, `test "$uploaded_release_id" = "$release_id"`)
verify := strings.Index(publishStep, "tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh")
download := strings.LastIndex(publishStep, "tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh")
byteCompare := strings.Index(publishStep, `cmp -s "$local_asset" "$remote_asset"`)
publish := strings.Index(publishStep, "-F draft=false")
if tagGuard == -1 || draftPatch == -1 || bodyVerify == -1 || markerVerify == -1 ||
upload == -1 || idRecheck == -1 || verify == -1 || download == -1 || byteCompare == -1 || publish == -1 ||
!(tagGuard < draftPatch && draftPatch < bodyVerify && bodyVerify < markerVerify && markerVerify < upload &&
upload < idRecheck && idRecheck < verify && verify < download && download < byteCompare && byteCompare < publish) {
t.Fatal("Draft must retain one release ID through upload, exact verification, download, byte comparison, and publication")
}
terminalStep := publishJob[strings.Index(publishJob, " - name: Require immutable published GitHub Release\n"):]
for _, required := range []string{
`RELEASE_ID: ${{ steps.publish.outputs.release_id }}`,
`RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }}`,
`"repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID"`,
`DWS_GITHUB_RELEASE_ID="$RELEASE_ID"`,
`tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh`,
`tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh`,
`[.tag_name, .draft, .prerelease, .immutable] | @tsv`,
`printf '%s\tfalse\t%s\ttrue' "$RELEASE_VERSION" "$expected_prerelease"`,
"Immutable GitHub Release notes differ from the sealed CHANGELOG.",
"Immutable GitHub Release is not bound to this exact recovery run.",
`DWS_PACKAGE_DIST_DIR="$immutable_dir"`,
`cmp -s "$sealed_asset" "$immutable_asset"`,
} {
if !strings.Contains(terminalStep, required) {
t.Errorf("terminal immutable release gate is missing %q", required)
}
}
immutableState := strings.Index(terminalStep, `[.tag_name, .draft, .prerelease, .immutable] | @tsv`)
immutableBody := strings.Index(terminalStep, "Immutable GitHub Release notes differ from the sealed CHANGELOG.")
immutableDownload := strings.Index(terminalStep, "tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh")
immutableVerify := strings.Index(terminalStep, `DWS_PACKAGE_DIST_DIR="$immutable_dir"`)
immutableCompare := strings.Index(terminalStep, `cmp -s "$sealed_asset" "$immutable_asset"`)
if immutableState == -1 || immutableBody == -1 || immutableDownload == -1 || immutableVerify == -1 || immutableCompare == -1 ||
!(immutableState < immutableBody && immutableBody < immutableDownload && immutableDownload < immutableVerify && immutableVerify < immutableCompare) {
t.Fatal("terminal gate must reverify immutable notes and exact sealed bytes on the locked release ID")
}
}
func TestRecoverReleaseBindsOneFailedRunAttempt(t *testing.T) {
t.Parallel()
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "recover-release.sh"))
if err != nil {
t.Fatalf("Abs(recover-release.sh) error = %v", err)
}
data, err := os.ReadFile(scriptPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", scriptPath, err)
}
script := string(data)
for _, required := range []string{
"--failed-attempt <attempt>",
"--failed-attempt requires --failed-run",
"find_latest_failed_attempt",
`while [ "$find_attempt" -ge 1 ]`,
"actions/runs/$find_run_id/attempts/$find_attempt",
`select(.head_sha == \"$commit\" and .head_branch == \"$VERSION\")`,
"Release run %s has no failed attempt",
`[.id, .run_attempt, .repository.full_name, .path, .event, .status, .conclusion, .head_branch, .head_sha] | @tsv`,
"actions/runs/%s/attempts/%s",
`-f "recover_failed_run_attempt=$FAILED_RUN_ATTEMPT"`,
} {
if !strings.Contains(script, required) {
t.Errorf("release recovery script is missing attempt binding %q", required)
}
}
}
func TestReleaseWorkflowPublicationBypassesSkippedDispatchButStopsOnCancellation(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
tests := []struct {
name string
start string
end string
condition string
}{
{
name: "release contract",
start: " release-contract:\n",
end: "\n release:\n",
condition: `if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success')) }}`,
},
{
name: "build",
start: " release:\n",
end: "\n verify-darwin-signatures:\n",
condition: `if: ${{ !cancelled() && needs.release-contract.result == 'success' }}`,
},
{
name: "Darwin verification",
start: " verify-darwin-signatures:\n",
end: "\n publish-release:\n",
condition: `if: ${{ !cancelled() && needs.release-contract.result == 'success' && needs.release.result == 'success' }}`,
},
{
name: "GitHub publication",
start: " publish-release:\n",
end: "\n publish-channels:\n",
condition: `if: ${{ !cancelled() && needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.verify-darwin-signatures.result == 'success' }}`,
},
{
name: "channel publication",
start: " publish-channels:\n",
end: "\n mirror-gitee-release:\n",
condition: `if: ${{ !cancelled() && needs.release-contract.result == 'success' && needs.publish-release.result == 'success' }}`,
},
{
name: "Gitee mirror",
start: " mirror-gitee-release:\n",
end: "\n repair-npm:\n",
condition: `if: ${{ !cancelled() && vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.publish-channels.result == 'success' }}`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
section := releaseWorkflowSection(t, workflow, test.start, test.end)
if !strings.Contains(section, test.condition) {
t.Errorf("%s must override skipped dispatch ancestors while preserving cancellation and dependency gates", test.name)
}
})
}
}
func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
gate := releaseWorkflowSection(t, workflow, " release-delivery-gate:\n", "\n repair-gitee:\n")
for _, required := range []string{
"name: Release delivery gate",
`if: ${{ !cancelled() }}`,
"- dispatch-contract",
"- authorize-recovery",
"- governance-preflight",
"- release-contract",
"- release",
"- verify-darwin-signatures",
"- publish-release",
"- publish-channels",
"- mirror-gitee-release",
"- repair-npm",
"- repair-gitee",
`REPAIR_GITEE_RESULT: ${{ needs.repair-gitee.result }}`,
"require_publication",
`require_result release-contract "$RELEASE_CONTRACT_RESULT" success`,
`require_result release "$RELEASE_RESULT" success`,
`require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" success`,
`require_result publish-release "$PUBLISH_RELEASE_RESULT" success`,
`require_result publish-channels "$PUBLISH_CHANNELS_RESULT" success`,
"workflow_dispatch:recover_release",
"workflow_dispatch:governance_preflight",
"workflow_dispatch:repair_npm",
"workflow_dispatch:repair_gitee",
`require_result repair-gitee "$REPAIR_GITEE_RESULT" success`,
`require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped`,
"unsupported release mode",
} {
if !strings.Contains(gate, required) {
t.Errorf("release delivery gate is missing %q", required)
}
}
}
func TestReleaseWorkflowUploadsPostProcessedDarwinAssets(t *testing.T) {
t.Parallel()
@@ -762,7 +1259,7 @@ func TestReleaseWorkflowUsesAppleCodesignBeforePublication(t *testing.T) {
}
codesign := strings.Index(workflow[verifyJob:publishJob], "codesign --verify --strict --verbose=4")
publish := strings.Index(workflow[publishJob:], `gh release edit "$GITHUB_REF_NAME" --draft=false`)
publish := strings.Index(workflow[publishJob:], "-F draft=false")
if codesign == -1 || publish == -1 {
t.Fatal("macOS codesign verification and explicit Draft publication are required")
}
@@ -824,14 +1321,25 @@ func TestReleaseWorkflowOpensHomebrewPROnlyForOfficialStableTags(t *testing.T) {
t.Fatal("the built-in GITHUB_TOKEN must not receive pull-request write permission")
}
for _, required := range []string{
"Check Homebrew PR automation token",
"Verify Homebrew PR automation permission",
"secrets.HOMEBREW_PR_TOKEN",
"HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases",
"verify-homebrew-pr-token.sh",
"--canary",
"HOMEBREW_PR_TOKEN and RELEASE_GOVERNANCE_TOKEN must use separate least-privilege identities",
} {
if !strings.Contains(workflow, required) {
t.Errorf("release workflow is missing Homebrew PR token preflight %q", required)
}
}
if got := strings.Count(workflow, "Verify Homebrew PR automation permission"); got != 2 {
t.Errorf("Homebrew PR permission preflight count = %d, want one default-branch preflight and one tag contract", got)
}
tagContract := releaseWorkflowSection(t, workflow, " release-contract:\n", "\n release:\n")
tagCI := strings.Index(tagContract, "Require successful CI Gate on the sealed commit")
tagHomebrew := strings.Index(tagContract, "Verify Homebrew PR automation permission")
if tagCI == -1 || tagHomebrew == -1 || tagCI > tagHomebrew {
t.Error("tag contract must validate the sealed CI Gate before exposing Homebrew credentials")
}
start := strings.Index(workflow, "- name: Open stable Homebrew formula PR")
if start == -1 {
@@ -848,7 +1356,7 @@ func TestReleaseWorkflowOpensHomebrewPROnlyForOfficialStableTags(t *testing.T) {
"./scripts/release/publish-homebrew-formula.sh",
"secrets.HOMEBREW_PR_TOKEN",
"DWS_TAP_PR_REPOSITORY",
"automation/homebrew-${{ github.ref_name }}",
"automation/homebrew-${{ needs.release-contract.outputs.release_version }}",
} {
if !strings.Contains(section, required) {
t.Errorf("Homebrew publication step is missing %q", required)
@@ -891,7 +1399,7 @@ func TestReleaseWorkflowOpensVersionedHomebrewPRForBetaTags(t *testing.T) {
"dist/homebrew/dingtalk-workspace-cli-beta.rb",
"Formula/dingtalk-workspace-cli-beta.rb",
"secrets.HOMEBREW_PR_TOKEN",
"automation/homebrew-beta-${{ github.ref_name }}",
"automation/homebrew-beta-${{ needs.release-contract.outputs.release_version }}",
} {
if !strings.Contains(section, required) {
t.Errorf("beta Homebrew PR step is missing %q", required)
+14
View File
@@ -30,10 +30,24 @@ func newReleaseEntryFixture(t *testing.T) *releaseEntryFixture {
}
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), fakeDelegate("prepare"), 0o755)
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "release.sh"), fakeDelegate("release"), 0o755)
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "recover-release.sh"), fakeDelegate("recover"), 0o755)
r.commitAndPush(t, "install unified release entry fixture")
return &releaseEntryFixture{repo: r, entry: entry, log: filepath.Join(t.TempDir(), "calls.log")}
}
func TestDWSReleaseEntryRoutesProtectedRecovery(t *testing.T) {
f := newReleaseEntryFixture(t)
f.configureRemote(t, "origin")
output, err := f.run(t, nil, "recover", "v1.0.1-beta.1", "--failed-run", "12345", "--failed-attempt", "2")
if err != nil {
t.Fatalf("dws-release recovery route error = %v\noutput:\n%s", err, output)
}
want := "recover\tv1.0.1-beta.1\t--remote\torigin\t--failed-run\t12345\t--failed-attempt\t2\n"
if got := f.callLog(t); got != want {
t.Fatalf("recovery delegate calls = %q, want %q", got, want)
}
}
func (f *releaseEntryFixture) run(t *testing.T, extraEnv []string, args ...string) (string, error) {
t.Helper()
cmd := exec.Command("sh", append([]string{f.entry}, args...)...)
+825 -7
View File
@@ -1,6 +1,7 @@
package scripts_test
import (
"bytes"
"crypto/sha256"
"fmt"
"os"
@@ -8,7 +9,9 @@ import (
"path/filepath"
"sort"
"strings"
"syscall"
"testing"
"time"
)
var releasePlatformAssets = []string{
@@ -256,7 +259,25 @@ func TestReleaseGitHubAssetSetIsExact(t *testing.T) {
}
binDir := t.TempDir()
fakeGH := filepath.Join(binDir, "gh")
mustWriteFile(t, fakeGH, []byte("#!/bin/sh\nset -eu\nprintf '%s\\n' \"$GH_ASSETS\"\n"), 0o755)
mustWriteFile(t, fakeGH, []byte(`#!/bin/sh
set -eu
case "${1:-}" in
release)
[ "${2:-}" = "view" ] || exit 2
[ "${GH_DRAFT:-false}" = "true" ] || exit 1
printf '123\n'
;;
api)
printf '%s\n' "$*" >> "$GH_LOG"
case "$*" in
*tag_name*) printf '%s\n' "$GH_RELEASE_TAG" ;;
*assets*name*) printf '%s\n' "$GH_ASSETS" ;;
*) exit 2 ;;
esac
;;
*) exit 2 ;;
esac
`), 0o755)
exact := strings.Join([]string{
"dws-windows-arm64.zip",
"dws-linux-amd64.tar.gz",
@@ -268,23 +289,218 @@ func TestReleaseGitHubAssetSetIsExact(t *testing.T) {
"dws-darwin-arm64.tar.gz",
}, "\n")
script := filepath.Join(sourceRoot, "scripts", "release", "verify-github-release-assets.sh")
run := func(assets string) (string, error) {
run := func(assets string, draft bool, releaseTag, releaseID string) (string, string, error) {
draftValue := "false"
if draft {
draftValue = "true"
}
logPath := filepath.Join(t.TempDir(), "gh.log")
cmd := exec.Command("sh", script, "v1.2.3")
cmd.Env = []string{
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
"HOME=" + t.TempDir(),
"GITHUB_REPOSITORY=owner/repo",
"GH_ASSETS=" + assets,
"GH_DRAFT=" + draftValue,
"GH_LOG=" + logPath,
"GH_RELEASE_TAG=" + releaseTag,
"DWS_GITHUB_RELEASE_ID=" + releaseID,
}
output, err := cmd.CombinedOutput()
return string(output), err
log, readErr := os.ReadFile(logPath)
if readErr != nil && !os.IsNotExist(readErr) {
t.Fatalf("ReadFile(%s) error = %v", logPath, readErr)
}
return string(output), string(log), err
}
if output, err := run(exact); err != nil {
if output, _, err := run(exact, false, "v1.2.3", ""); err != nil {
t.Fatalf("exact GitHub assets rejected: %v\n%s", err, output)
}
if output, err := run(exact + "\nmalware.exe"); err == nil || !strings.Contains(output, "exactly the supported assets") {
if output, log, err := run(exact, true, "v1.2.3", ""); err != nil {
t.Fatalf("exact Draft GitHub assets rejected: %v\n%s", err, output)
} else if !strings.Contains(log, "repos/owner/repo/releases/123") ||
strings.Contains(log, "repos/owner/repo/releases/tags/v1.2.3") {
t.Fatalf("Draft verification did not use the release ID endpoint:\n%s", log)
}
if output, _, err := run(exact+"\nmalware.exe", false, "v1.2.3", ""); err == nil || !strings.Contains(output, "exactly the supported assets") {
t.Fatalf("extra GitHub asset was not rejected: err=%v\n%s", err, output)
}
if output, _, err := run(exact, true, "v9.9.9", ""); err == nil || !strings.Contains(output, "ID/tag mismatch") {
t.Fatalf("wrong Draft release ID target was not rejected: err=%v\n%s", err, output)
}
if output, _, err := run(exact, false, "v1.2.3", "invalid"); err == nil || !strings.Contains(output, "invalid GitHub Release ID") {
t.Fatalf("invalid explicit release ID was not rejected: err=%v\n%s", err, output)
}
}
func TestReleaseGitHubAssetsDownloadByExactReleaseID(t *testing.T) {
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
binDir := t.TempDir()
fakeGH := filepath.Join(binDir, "gh")
mustWriteFile(t, fakeGH, []byte(`#!/bin/sh
set -eu
[ "${1:-}" = "api" ] || exit 2
shift
accept=""
endpoint=""
query=""
while [ "$#" -gt 0 ]; do
case "$1" in
-H)
accept="${2:-}"
shift 2
;;
--jq)
query="${2:-}"
shift 2
;;
repos/*)
endpoint="$1"
shift
;;
*)
shift
;;
esac
done
printf '%s|%s|%s\n' "$accept" "$endpoint" "$query" >> "$GH_LOG"
case "$accept" in
*application/octet-stream*)
case "$endpoint" in
repos/owner/repo/releases/assets/*) ;;
*) exit 31 ;;
esac
asset_id="${endpoint##*/}"
asset_name="$(
awk -F ' ' -v id="$asset_id" '
$1 == id { print $2; found = 1 }
END { if (!found) exit 1 }
' "$GH_ASSET_ROWS"
)" || exit 32
printf 'asset-bytes:%s:%s\n' "$asset_id" "$asset_name"
exit 0
;;
esac
[ "$endpoint" = "repos/owner/repo/releases/$GH_EXPECTED_RELEASE_ID" ] || exit 33
case "$query" in
'.tag_name')
printf '%s\n' "$GH_RELEASE_TAG"
;;
'.assets[].name')
cut -f 2 "$GH_ASSET_ROWS"
;;
'.assets[] | [.id, .name] | @tsv')
cat "$GH_ASSET_ROWS"
;;
*)
exit 34
;;
esac
`), 0o755)
type releaseAsset struct {
id string
name string
}
assets := []releaseAsset{
{id: "1008", name: "dws-windows-arm64.zip"},
{id: "1003", name: "dws-darwin-arm64.tar.gz"},
{id: "1001", name: "checksums.txt"},
{id: "1005", name: "dws-linux-arm64.tar.gz"},
{id: "1002", name: "dws-darwin-amd64.tar.gz"},
{id: "1007", name: "dws-windows-amd64.zip"},
{id: "1004", name: "dws-linux-amd64.tar.gz"},
{id: "1006", name: "dws-skills.zip"},
}
rowsFor := func(items []releaseAsset) string {
var rows []string
for _, asset := range items {
rows = append(rows, asset.id+"\t"+asset.name)
}
return strings.Join(rows, "\n") + "\n"
}
script := filepath.Join(sourceRoot, "scripts", "release", "download-github-release-assets.sh")
type runResult struct {
output string
log string
dest string
err error
}
run := func(items []releaseAsset, releaseTag, releaseID string) runResult {
runDir := t.TempDir()
rowsPath := filepath.Join(runDir, "assets.tsv")
logPath := filepath.Join(runDir, "gh.log")
dest := filepath.Join(runDir, "download")
mustWriteFile(t, rowsPath, []byte(rowsFor(items)), 0o644)
cmd := exec.Command("sh", script, "v1.2.3", dest)
cmd.Env = []string{
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
"HOME=" + t.TempDir(),
"GITHUB_REPOSITORY=owner/repo",
"DWS_GITHUB_RELEASE_ID=" + releaseID,
"GH_ASSET_ROWS=" + rowsPath,
"GH_EXPECTED_RELEASE_ID=456",
"GH_LOG=" + logPath,
"GH_RELEASE_TAG=" + releaseTag,
}
output, runErr := cmd.CombinedOutput()
log, readErr := os.ReadFile(logPath)
if readErr != nil && !os.IsNotExist(readErr) {
t.Fatalf("ReadFile(%s) error = %v", logPath, readErr)
}
return runResult{
output: string(output),
log: string(log),
dest: dest,
err: runErr,
}
}
success := run(assets, "v1.2.3", "456")
if success.err != nil {
t.Fatalf("exact GitHub Release download was rejected: %v\n%s", success.err, success.output)
}
if strings.Contains(success.log, "releases/tags/") {
t.Fatalf("download fell back to a tag endpoint:\n%s", success.log)
}
if got := strings.Count(success.log, "application/octet-stream"); got != len(assets) {
t.Fatalf("asset download count = %d, want %d\nlog:\n%s", got, len(assets), success.log)
}
for _, asset := range assets {
wantEndpoint := "repos/owner/repo/releases/assets/" + asset.id
if !strings.Contains(success.log, wantEndpoint) {
t.Errorf("asset %s was not downloaded by ID %s\nlog:\n%s", asset.name, asset.id, success.log)
}
data, readErr := os.ReadFile(filepath.Join(success.dest, asset.name))
if readErr != nil {
t.Errorf("ReadFile(%s) error = %v", asset.name, readErr)
continue
}
want := fmt.Sprintf("asset-bytes:%s:%s\n", asset.id, asset.name)
if string(data) != want {
t.Errorf("%s bytes = %q, want %q", asset.name, data, want)
}
}
extra := append(append([]releaseAsset{}, assets...), releaseAsset{id: "1009", name: "malware.exe"})
if result := run(extra, "v1.2.3", "456"); result.err == nil || !strings.Contains(result.output, "exactly the supported assets") {
t.Fatalf("extra GitHub Release asset was not rejected: err=%v\n%s", result.err, result.output)
}
if result := run(assets[:len(assets)-1], "v1.2.3", "456"); result.err == nil || !strings.Contains(result.output, "exactly the supported assets") {
t.Fatalf("missing GitHub Release asset was not rejected: err=%v\n%s", result.err, result.output)
}
if result := run(assets, "v9.9.9", "456"); result.err == nil || !strings.Contains(result.output, "ID/tag mismatch") {
t.Fatalf("wrong GitHub Release tag was not rejected: err=%v\n%s", result.err, result.output)
}
if result := run(assets, "v1.2.3", "invalid"); result.err == nil || !strings.Contains(result.output, "invalid GitHub Release ID") {
t.Fatalf("invalid GitHub Release ID was not rejected: err=%v\n%s", result.err, result.output)
}
}
func TestReleaseDeliveredStableRequiresSuccessfulPublicDelivery(t *testing.T) {
@@ -392,6 +608,72 @@ esac
}
}
func TestReleaseWorkflowDeliveryAcceptsOnlySharedProtectedRecovery(t *testing.T) {
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
binDir := t.TempDir()
fakeCurl := filepath.Join(binDir, "curl")
mustWriteFile(t, fakeCurl, []byte(`#!/bin/sh
set -eu
for argument in "$@"; do endpoint="$argument"; done
case "$endpoint" in
*event=push*)
printf '{"workflow_runs":[]}\n'
;;
*event=workflow_dispatch*)
printf '{"workflow_runs":[{"id":42,"display_title":"Release recovery %s at %s %s-1700000000-42","event":"workflow_dispatch","status":"completed","conclusion":"success","head_branch":"main","head_sha":"%s","path":".github/workflows/release.yml","repository":{"full_name":"owner/repo"}}]}\n' "$TAG" "$RELEASE_COMMIT" "$RELEASE_COMMIT" "$WORKFLOW_SHA"
;;
*/compare/*...main)
printf '{"status":"ahead"}\n'
;;
*/actions/runs/42/jobs*)
printf '{"jobs":['
printf '{"name":"Build signed release artifacts","status":"completed","conclusion":"success","head_sha":"%s"},' "$WORKFLOW_SHA"
printf '{"name":"Verify Apple Developer ID signatures","status":"completed","conclusion":"success","head_sha":"%s"},' "$WORKFLOW_SHA"
printf '{"name":"Publish immutable GitHub Release","status":"completed","conclusion":"success","head_sha":"%s"}' "$WORKFLOW_SHA"
if [ "${MISSING_CHANNELS:-0}" != 1 ]; then
printf ',{"name":"Publish npm and mirrors","status":"completed","conclusion":"success","head_sha":"%s"}' "$WORKFLOW_SHA"
fi
printf ']}\n'
;;
*) exit 1 ;;
esac
`), 0o755)
script := filepath.Join(sourceRoot, "scripts", "release", "verify-release-workflow-delivery.sh")
tag := "v1.2.3-beta.1"
commit := strings.Repeat("a", 40)
workflowSHA := strings.Repeat("b", 40)
run := func(missingChannels bool) (string, error) {
cmd := exec.Command("sh", script, tag, commit)
missing := "0"
if missingChannels {
missing = "1"
}
cmd.Env = []string{
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
"HOME=" + t.TempDir(),
"DWS_RELEASE_OFFICIAL_REPOSITORY=owner/repo",
"TAG=" + tag,
"RELEASE_COMMIT=" + commit,
"WORKFLOW_SHA=" + workflowSHA,
"MISSING_CHANNELS=" + missing,
}
output, err := cmd.CombinedOutput()
return string(output), err
}
output, err := run(false)
if err != nil || !strings.Contains(output, "protected recovery run 42") {
t.Fatalf("protected shared recovery was rejected: err=%v\noutput:\n%s", err, output)
}
output, err = run(true)
if err == nil || !strings.Contains(output, "did not complete the shared release job graph") {
t.Fatalf("incomplete recovery job graph passed: err=%v\noutput:\n%s", err, output)
}
}
func releaseChangelog(sections ...string) string {
text := "# Changelog\n\n## [Unreleased]\n\n"
for _, section := range sections {
@@ -1002,6 +1284,542 @@ func TestReleaseCommandValidatesThenPushesAnnotatedTag(t *testing.T) {
}
}
func TestReleaseCommandReusesExactPreflightProof(t *testing.T) {
r := newReleaseTestRepo(t)
installReleaseCommandFixture(t, r)
mustWriteFile(t, filepath.Join(r.root, ".gitignore"), []byte("/dws\n"), 0o644)
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte(`test:
@rm -f dws
@printf 'test\n' >> "$$(git rev-parse --git-path release-phases)"
build:
@printf '#!/bin/sh\nexit 0\n' > dws
@chmod +x dws
@printf 'build\n' >> "$$(git rev-parse --git-path release-phases)"
policy:
@test -x dws
@printf 'policy\n' >> "$$(git rev-parse --git-path release-phases)"
package:
@printf 'package\n' >> "$$(git rev-parse --git-path release-phases)"
`), 0o644)
r.commitAndPush(t, "install proof-aware release automation")
command := filepath.Join(r.root, "scripts", "release", "release.sh")
output, err := runReleaseScript(t, r.root, command,
"prerelease", "v1.0.1-beta.1", "--remote", "origin",
)
if err != nil {
t.Fatalf("release validation error = %v\noutput:\n%s", err, output)
}
if !strings.Contains(output, "Publish within six hours") {
t.Fatalf("validation output missing reusable-proof guidance:\n%s", output)
}
phasePath := filepath.Join(r.root, ".git", "release-phases")
phaseData, err := os.ReadFile(phasePath)
if err != nil {
t.Fatalf("ReadFile(release phases) error = %v", err)
}
wantPhases := "test\nbuild\npolicy\npackage\n"
if string(phaseData) != wantPhases {
t.Fatalf("release phase order = %q, want %q", phaseData, wantPhases)
}
proofPath := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "--git-path", "dws-release-preflight/v1.0.1-beta.1.proof"))
if !filepath.IsAbs(proofPath) {
proofPath = filepath.Join(r.root, proofPath)
}
proofBefore, err := os.ReadFile(proofPath)
if err != nil {
t.Fatalf("ReadFile(preflight proof) error = %v", err)
}
hook := filepath.Join(r.remote, "hooks", "pre-receive")
mustWriteFile(t, hook, []byte("#!/bin/sh\nset -eu\nwhile read -r old new ref; do\n case \"$ref\" in refs/tags/*) exit 1 ;; esac\ndone\nexit 0\n"), 0o755)
failedOutput, failedErr := runReleaseScript(t, r.root, command,
"prerelease", "v1.0.1-beta.1", "--remote", "origin", "--publish", "--yes",
)
if failedErr == nil || !strings.Contains(failedOutput, "new local tag was removed") {
t.Fatalf("rejected proof-based publish did not fail safely: err=%v\noutput:\n%s", failedErr, failedOutput)
}
proofAfterFailure, err := os.ReadFile(proofPath)
if err != nil {
t.Fatalf("ReadFile(preflight proof after failure) error = %v", err)
}
if string(proofAfterFailure) != string(proofBefore) {
t.Fatalf("failed publish refreshed reusable proof:\nbefore:\n%s\nafter:\n%s", proofBefore, proofAfterFailure)
}
if err := os.Remove(hook); err != nil {
t.Fatalf("Remove(rejecting hook) error = %v", err)
}
output, err = runReleaseScript(t, r.root, command,
"prerelease", "v1.0.1-beta.1", "--remote", "origin", "--publish", "--yes",
)
if err != nil {
t.Fatalf("release publish error = %v\noutput:\n%s", err, output)
}
if !strings.Contains(output, "Reusing exact preflight proof") {
t.Fatalf("publish did not reuse the exact preflight proof:\n%s", output)
}
phaseData, err = os.ReadFile(phasePath)
if err != nil {
t.Fatalf("ReadFile(release phases after publish) error = %v", err)
}
if string(phaseData) != wantPhases {
t.Fatalf("publish reran expensive phases: %q", phaseData)
}
}
func TestReleaseGovernanceCIDispatchBindsExactRunIdentity(t *testing.T) {
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
binDir := t.TempDir()
stateDir := t.TempDir()
fakeGH := filepath.Join(binDir, "gh")
mustWriteFile(t, fakeGH, []byte(`#!/bin/sh
set -eu
command="$1"
shift
case "$command" in
workflow)
test "$1" = run
shift
printf '%s\n' "$@" > "$GH_STATE/workflow-args"
for argument in "$@"; do
case "$argument" in
governance_preflight_nonce=*)
printf 'Release governance preflight %s\n' "${argument#governance_preflight_nonce=}" > "$GH_STATE/title"
;;
governance_preflight_commit=*)
printf '%s\n' "${argument#governance_preflight_commit=}" > "$GH_STATE/commit"
;;
esac
done
;;
api)
for argument in "$@"; do
case "$argument" in repos/*/actions/*) endpoint="$argument" ;; esac
done
case "$endpoint" in
*/actions/workflows/release.yml/runs*) printf '42\n' ;;
*/actions/runs/42)
title="$(cat "$GH_STATE/title")"
commit="$(cat "$GH_STATE/commit")"
repository=owner/repo
if [ "${GH_BAD_STATE:-0}" = 1 ]; then repository=attacker/repo; fi
printf '%s\tworkflow_dispatch\tcompleted\tsuccess\tmain\t%s\t.github/workflows/release.yml\t%s\n' "$title" "$commit" "$repository"
;;
*) exit 1 ;;
esac
;;
run)
test "$1" = watch
;;
*) exit 1 ;;
esac
`), 0o755)
script := filepath.Join(sourceRoot, "scripts", "release", "verify-release-governance-ci.sh")
commit := strings.Repeat("a", 40)
run := func(badState bool) (string, error) {
cmd := exec.Command("sh", script, "owner/repo", commit)
cmd.Env = []string{
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
"HOME=" + t.TempDir(),
"GH_STATE=" + stateDir,
fmt.Sprintf("GH_BAD_STATE=%d", map[bool]int{false: 0, true: 1}[badState]),
}
output, err := cmd.CombinedOutput()
return string(output), err
}
output, err := run(false)
if err != nil || !strings.Contains(output, "Release governance preflight passed") {
t.Fatalf("governance CI preflight error = %v\noutput:\n%s", err, output)
}
args, err := os.ReadFile(filepath.Join(stateDir, "workflow-args"))
if err != nil {
t.Fatalf("ReadFile(workflow args) error = %v", err)
}
for _, required := range []string{
"--ref\nmain\n",
"governance_preflight_commit=" + commit,
"governance_preflight_nonce=" + commit + "-",
} {
if !strings.Contains(string(args), required) {
t.Errorf("workflow dispatch args are missing %q:\n%s", required, args)
}
}
output, err = run(true)
if err == nil || !strings.Contains(output, "run identity mismatch") {
t.Fatalf("mismatched governance run identity passed: err=%v\noutput:\n%s", err, output)
}
}
func TestVerifyHomebrewPRTokenRequiresMinimalWorkingIdentity(t *testing.T) {
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
binDir := t.TempDir()
fakeGH := filepath.Join(binDir, "gh")
mustWriteFile(t, fakeGH, []byte(`#!/bin/sh
set -eu
test "${GH_TOKEN:-}" = valid-token
command="$1"
shift
test "$command" = api
endpoint=""
for argument in "$@"; do
case "$argument" in
repos/*|user) endpoint="$argument" ;;
esac
done
case "$endpoint" in
repos/owner/repo)
printf 'HTTP/2.0 200 OK\nX-OAuth-Scopes: %s\n\nowner/repo\t%s\tmain\n' \
"${GH_SCOPES-public_repo}" "${GH_CAN_PUSH:-true}"
;;
repos/owner/repo/pulls?state=open\&per_page=1) ;;
user) printf '%s\n' release-bot ;;
*) exit 1 ;;
esac
`), 0o755)
script := filepath.Join(sourceRoot, "scripts", "release", "verify-homebrew-pr-token.sh")
run := func(token, scopes, canPush string) (string, error) {
cmd := exec.Command("sh", script, "owner/repo")
cmd.Env = []string{
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
"HOME=" + t.TempDir(),
"HOMEBREW_PR_TOKEN=" + token,
"GH_SCOPES=" + scopes,
"GH_CAN_PUSH=" + canPush,
}
output, err := cmd.CombinedOutput()
return string(output), err
}
output, err := run("valid-token", "public_repo", "true")
if err != nil || !strings.Contains(output, "Homebrew PR token capability passed for owner/repo as release-bot (classic-public_repo)") {
t.Fatalf("valid Homebrew PR token rejected: err=%v\noutput:\n%s", err, output)
}
output, err = run("valid-token", "repo", "true")
if err == nil || !strings.Contains(output, "must have only public_repo scope") {
t.Fatalf("overprivileged Homebrew PR token accepted: err=%v\noutput:\n%s", err, output)
}
output, err = run("valid-token", "", "true")
if err != nil || !strings.Contains(output, "(fine-grained)") {
t.Fatalf("fine-grained Homebrew PR token rejected: err=%v\noutput:\n%s", err, output)
}
output, err = run("valid-token", "public_repo", "false")
if err == nil || !strings.Contains(output, "does not have push permission") {
t.Fatalf("non-pushing Homebrew identity accepted: err=%v\noutput:\n%s", err, output)
}
output, err = run("", "public_repo", "true")
if err == nil || !strings.Contains(output, "HOMEBREW_PR_TOKEN is required") {
t.Fatalf("missing Homebrew token accepted: err=%v\noutput:\n%s", err, output)
}
}
func TestVerifyHomebrewPRTokenCanaryCreatesAndCleans(t *testing.T) {
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
r := newReleaseTestRepo(t)
binDir := t.TempDir()
stateDir := t.TempDir()
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatalf("LookPath(git) error = %v", err)
}
fakeGit := filepath.Join(binDir, "git")
mustWriteFile(t, fakeGit, []byte(`#!/bin/sh
set -eu
network=false
for argument in "$@"; do
case "$argument" in
clone|push|ls-remote) network=true ;;
esac
done
if [ "$network" = true ]; then
printf '%s\n' "$*" >> "$GH_STATE/git-network-args"
case " $* " in
*" https://github.com/owner/repo.git "*) ;;
*) exit 91 ;;
esac
test "${GIT_CONFIG_NOSYSTEM:-}" = 1
test "${GIT_CONFIG_GLOBAL:-}" = /dev/null
test "${GIT_CONFIG_COUNT:-}" = 0
test -x "${GIT_ASKPASS:-}"
test "$("$GIT_ASKPASS" Username)" = x-access-token
test "$("$GIT_ASKPASS" Password)" = valid-token
case " $* " in
*" HEAD:refs/heads/"*)
if [ "${BLOCK_PUSH:-false}" = true ]; then
"$REAL_GIT" \
-c "url.$TEST_REMOTE.insteadOf=https://github.com/owner/repo.git" \
"$@"
: > "$GH_STATE/push-complete-${GITHUB_RUN_ID:-unknown}"
sleep 2
exit 0
fi
;;
esac
case " $* " in
*" :refs/heads/"*)
if [ "${FAIL_DELETE:-false}" = true ]; then
exit 92
fi
;;
esac
exec "$REAL_GIT" \
-c "url.$TEST_REMOTE.insteadOf=https://github.com/owner/repo.git" \
"$@"
fi
exec "$REAL_GIT" "$@"
`), 0o755)
fakeGH := filepath.Join(binDir, "gh")
mustWriteFile(t, fakeGH, []byte(`#!/bin/sh
set -eu
test "${GH_TOKEN:-}" = valid-token
command="$1"
shift
case "$command" in
api)
endpoint=""
for argument in "$@"; do
case "$argument" in
repos/*|user) endpoint="$argument" ;;
esac
done
case "$endpoint" in
repos/owner/repo)
printf 'HTTP/2.0 200 OK\nX-OAuth-Scopes: public_repo\n\nowner/repo\ttrue\tmain\n'
;;
repos/owner/repo/pulls?state=open\&per_page=1) ;;
repos/owner/repo/pulls?state=open\&head=owner:*\&base=main\&per_page=2)
if [ -f "$GH_STATE/pr-open-${GITHUB_RUN_ID:-unknown}" ]; then
printf '%s\n' 42
fi
;;
repos/owner/repo/pulls/42)
if [ "${FAIL_CLOSE:-false}" = true ]; then
exit 93
fi
rm -f "$GH_STATE/pr-open-${GITHUB_RUN_ID:-unknown}"
printf '%s\n' closed > "$GH_STATE/pr-closed-${GITHUB_RUN_ID:-unknown}"
;;
user) printf '%s\n' release-bot ;;
*) exit 1 ;;
esac
;;
pr)
test "$1" = create
shift
printf '%s\n' "$@" > "$GH_STATE/pr-args"
: > "$GH_STATE/pr-create-started-${GITHUB_RUN_ID:-unknown}"
if [ "${FAIL_PR_CREATE:-false}" = true ]; then
exit 94
fi
: > "$GH_STATE/pr-open-${GITHUB_RUN_ID:-unknown}"
if [ "${BLOCK_PR_CREATE:-false}" = true ]; then
sleep 2
fi
printf '%s\n' 'https://github.com/owner/repo/pull/42'
;;
*) exit 1 ;;
esac
`), 0o755)
script := filepath.Join(sourceRoot, "scripts", "release", "verify-homebrew-pr-token.sh")
baseEnv := []string{
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
"HOME=" + t.TempDir(),
"GH_STATE=" + stateDir,
"HOMEBREW_PR_TOKEN=valid-token",
"GITHUB_RUN_ATTEMPT=2",
"REAL_GIT=" + realGit,
"TEST_REMOTE=" + r.remote,
}
runCanary := func(runID string, extraEnv ...string) ([]byte, error) {
cmd := exec.Command("sh", script, "owner/repo", "--canary")
cmd.Dir = r.root
cmd.Env = append(append([]string{}, baseEnv...), "GITHUB_RUN_ID="+runID)
cmd.Env = append(cmd.Env, extraEnv...)
return cmd.CombinedOutput()
}
remoteHasBranch := func(branch string) bool {
cmd := exec.Command(
"git", "--git-dir", r.remote, "show-ref", "--verify",
"refs/heads/"+branch,
)
return cmd.Run() == nil
}
output, err := runCanary("12345")
if err != nil || !strings.Contains(string(output), "Homebrew PR token capability passed") {
t.Fatalf("Homebrew token canary error = %v\noutput:\n%s", err, output)
}
prArgs, err := os.ReadFile(filepath.Join(stateDir, "pr-args"))
if err != nil {
t.Fatalf("ReadFile(PR args) error = %v", err)
}
for _, required := range []string{
"--draft",
"--base\nmain",
"--head\nautomation/homebrew-token-canary-12345-2",
} {
if !strings.Contains(string(prArgs), required) {
t.Errorf("Homebrew token canary PR args are missing %q:\n%s", required, prArgs)
}
}
if _, err := os.Stat(filepath.Join(stateDir, "pr-closed-12345")); err != nil {
t.Fatalf("Homebrew token canary did not close its PR: %v", err)
}
gitNetworkArgs, err := os.ReadFile(filepath.Join(stateDir, "git-network-args"))
if err != nil {
t.Fatalf("ReadFile(git network args) error = %v", err)
}
for _, required := range []string{
"clone",
"https://github.com/owner/repo.git",
"HEAD:refs/heads/automation/homebrew-token-canary-12345-2",
"--force-with-lease=refs/heads/automation/homebrew-token-canary-12345-2:",
":refs/heads/automation/homebrew-token-canary-12345-2",
} {
if !strings.Contains(string(gitNetworkArgs), required) {
t.Errorf("Homebrew token canary Git operations are missing %q:\n%s", required, gitNetworkArgs)
}
}
if remoteHasBranch("automation/homebrew-token-canary-12345-2") {
t.Fatal("Homebrew token canary left its remote branch behind")
}
output, err = runCanary("20001", "FAIL_PR_CREATE=true")
if err == nil || !strings.Contains(string(output), "cannot create a canary pull request") {
t.Fatalf("PR creation failure was not fail-closed: err=%v\noutput:\n%s", err, output)
}
if remoteHasBranch("automation/homebrew-token-canary-20001-2") {
t.Fatal("PR creation failure left its canary branch behind")
}
output, err = runCanary("20002", "FAIL_CLOSE=true")
if err == nil ||
!strings.Contains(string(output), "could not close canary PR") ||
!strings.Contains(string(output), "could not clean up Homebrew token canary PR") {
t.Fatalf("PR cleanup failure was not fail-closed: err=%v\noutput:\n%s", err, output)
}
if remoteHasBranch("automation/homebrew-token-canary-20002-2") {
t.Fatal("PR cleanup failure left its canary branch behind")
}
if _, err := os.Stat(filepath.Join(stateDir, "pr-open-20002")); err != nil {
t.Fatalf("PR cleanup failure did not leave an observable open canary PR: %v", err)
}
output, err = runCanary("20003", "FAIL_DELETE=true")
if err == nil ||
!strings.Contains(string(output), "could not delete canary branch") ||
!strings.Contains(string(output), "could not clean up Homebrew token canary branch") {
t.Fatalf("branch cleanup failure was not fail-closed: err=%v\noutput:\n%s", err, output)
}
if !remoteHasBranch("automation/homebrew-token-canary-20003-2") {
t.Fatal("branch deletion failure was hidden instead of leaving an observable failed canary")
}
termCmd := exec.Command("sh", script, "owner/repo", "--canary")
termCmd.Dir = r.root
termCmd.Env = append(append([]string{}, baseEnv...),
"GITHUB_RUN_ID=20004",
"BLOCK_PR_CREATE=true",
)
var termStdout bytes.Buffer
var termStderr bytes.Buffer
termCmd.Stdout = &termStdout
termCmd.Stderr = &termStderr
if err := termCmd.Start(); err != nil {
t.Fatalf("Start(TERM canary) error = %v", err)
}
marker := filepath.Join(stateDir, "pr-create-started-20004")
deadline := time.Now().Add(5 * time.Second)
for {
if _, err := os.Stat(marker); err == nil {
break
}
if time.Now().After(deadline) {
_ = termCmd.Process.Kill()
_ = termCmd.Wait()
t.Fatalf("TERM canary did not reach PR creation:\n%s%s", termStdout.String(), termStderr.String())
}
time.Sleep(20 * time.Millisecond)
}
if err := termCmd.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatalf("Signal(TERM canary) error = %v", err)
}
err = termCmd.Wait()
exitErr, ok := err.(*exec.ExitError)
if !ok || exitErr.ExitCode() != 143 {
t.Fatalf("TERM canary exit = %v, want 143\nstdout:\n%s\nstderr:\n%s",
err, termStdout.String(), termStderr.String())
}
if remoteHasBranch("automation/homebrew-token-canary-20004-2") {
t.Fatal("TERM canary left its remote branch behind")
}
if _, err := os.Stat(filepath.Join(stateDir, "pr-closed-20004")); err != nil {
t.Fatalf("TERM canary did not close the PR created during the signal race: %v", err)
}
if _, err := os.Stat(filepath.Join(stateDir, "pr-open-20004")); !os.IsNotExist(err) {
t.Fatalf("TERM canary left an open PR marker: %v", err)
}
pushTermCmd := exec.Command("sh", script, "owner/repo", "--canary")
pushTermCmd.Dir = r.root
pushTermCmd.Env = append(append([]string{}, baseEnv...),
"GITHUB_RUN_ID=20005",
"BLOCK_PUSH=true",
)
var pushTermStdout bytes.Buffer
var pushTermStderr bytes.Buffer
pushTermCmd.Stdout = &pushTermStdout
pushTermCmd.Stderr = &pushTermStderr
if err := pushTermCmd.Start(); err != nil {
t.Fatalf("Start(push TERM canary) error = %v", err)
}
pushMarker := filepath.Join(stateDir, "push-complete-20005")
deadline = time.Now().Add(5 * time.Second)
for {
if _, err := os.Stat(pushMarker); err == nil {
break
}
if time.Now().After(deadline) {
_ = pushTermCmd.Process.Kill()
_ = pushTermCmd.Wait()
t.Fatalf("push TERM canary did not complete its remote push:\n%s%s",
pushTermStdout.String(), pushTermStderr.String())
}
time.Sleep(20 * time.Millisecond)
}
if err := pushTermCmd.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatalf("Signal(push TERM canary) error = %v", err)
}
err = pushTermCmd.Wait()
exitErr, ok = err.(*exec.ExitError)
if !ok || exitErr.ExitCode() != 143 {
t.Fatalf("push TERM canary exit = %v, want 143\nstdout:\n%s\nstderr:\n%s",
err, pushTermStdout.String(), pushTermStderr.String())
}
if remoteHasBranch("automation/homebrew-token-canary-20005-2") {
t.Fatal("push TERM canary left its remote branch behind")
}
}
func TestReleaseCommandCleansLocalTagWhenPushFails(t *testing.T) {
r := newReleaseTestRepo(t)
installReleaseCommandFixture(t, r)
@@ -1048,7 +1866,7 @@ func TestReleaseCommandRechecksAdvancedStableAuthority(t *testing.T) {
section := "## [1.0.2-beta.1] - 2026-07-11\n\n### Changed\n\n- Validate a candidate after stable authority advances.\n\n"
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(section)), 0o644)
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-command-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nprintf 'compatibility %s\\n' \"$*\"\n"), 0o755)
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\npolicy:\n\t@:\npackage:\n\t@git tag -a v1.0.1 -m 'Release v1.0.1'\n\t@git push origin refs/tags/v1.0.1\n"), 0o644)
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\nbuild:\n\t@:\npolicy:\n\t@:\npackage:\n\t@git tag -a v1.0.1 -m 'Release v1.0.1'\n\t@git push origin refs/tags/v1.0.1\n"), 0o644)
r.commitAndPush(t, "install advancing release fixture")
output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
@@ -1071,7 +1889,7 @@ func installReleaseCommandFixture(t *testing.T, r *releaseTestRepo) {
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-package-managers.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-release-artifacts.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-command-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\npolicy:\n\t@:\npackage:\n\t@:\n"), 0o644)
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\nbuild:\n\t@:\npolicy:\n\t@:\npackage:\n\t@:\n"), 0o644)
}
func releaseCopyFile(t *testing.T, source, target string, mode os.FileMode) {
+16
View File
@@ -50,6 +50,12 @@ var (
"dws skill find": true, // backward-compat stub
}
// Commands in this set are runnable leaves whose following token is a
// positional argument rather than another Cobra subcommand.
positionalLeafCommands = map[string]bool{
"dws schema": true,
}
// Argument-like tokens the parser should not mistake for sub-commands.
dateLikeRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}`)
)
@@ -212,6 +218,9 @@ func parseSubPath(cmd string) (subPath, leaf string, flags []string) {
if len(subTokens) == 0 && t != "dws" {
break // doesn't start with dws → not a real cmd
}
if positionalLeafCommands[strings.Join(subTokens, " ")] {
break
}
// Stop sub-path on noise tokens
if dateLikeRe.MatchString(t) || strings.Contains(t, "/") || strings.Contains(t, "*") {
break
@@ -226,6 +235,13 @@ func parseSubPath(cmd string) (subPath, leaf string, flags []string) {
return subPath, leaf, flags
}
func TestParseSubPathStopsAtSchemaPathArgument(t *testing.T) {
subPath, leaf, _ := parseSubPath("dws schema event.consume")
if subPath != "dws schema" || leaf != "schema" {
t.Fatalf("parseSubPath() = (%q, %q), want (%q, %q)", subPath, leaf, "dws schema", "schema")
}
}
// helpCache memoizes `dws <sub> --help` outputs.
type helpCache struct {
mu sync.Mutex
+80 -7
View File
@@ -994,7 +994,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
---
### event(10 条)
### event(18 条)
#### `dws event consume user_im_message_receive_at`
@@ -1005,18 +1005,24 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
#### `dws event consume user_im_message_receive_o2o`
**event_event_consume_o2o_001**
- Prompt: 监听我和 userId 507971 的单聊消息
- Expected: `dws event consume user_im_message_receive_o2o --user 507971 -f ndjson`
- Flags: `--user` = `507971`
- Prompt: 监听我和 userId test-user-001 的单聊消息
- Expected: `dws event consume user_im_message_receive_o2o --user test-user-001 -f ndjson`
- Flags: `--user` = `test-user-001`
**event_event_consume_o2o_open_id_001**
- Prompt: 监听我和 openDingtalkId abc 的单聊消息
- Expected: `dws event consume user_im_message_receive_o2o --open-dingtalk-id abc -f ndjson`
- Flags: `--open-dingtalk-id` = `abc`
**event_event_consume_o2o_003** `[ASK_USER]`
- Prompt: 监听我的个人单聊消息
- Expected: `dws event consume user_im_message_receive_o2o -f ndjson`
**event_event_consume_o2o_auto_reply_001**
- Prompt: 监听我和 userId 507971 的单聊消息,并对方发什么自动回复什么
- Expected: `dws event consume user_im_message_receive_o2o --user 507971 -f ndjson`
- Flags: `--user` = `507971`
- Prompt: 监听我和 userId test-user-001 的单聊消息,并对方发什么自动回复什么
- Expected: `dws event consume user_im_message_receive_o2o --user test-user-001 -f ndjson`
- Flags: `--user` = `test-user-001`
- Contract: 等待 stderr 的 `[event] ready`;从每行 NDJSON 顶层读取 `content` 和 `sender_open_dingtalk_id`,持续读取 stdout,不使用轮询或 output-dir watcher
#### `dws event consume user_im_message_receive_group`
@@ -1024,6 +1030,73 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
- Prompt: 监听 openConversationId cid123 的群消息
- Expected: `dws event consume user_im_message_receive_group --group cid123 -f ndjson`
- Flags: `--group` = `cid123`
- Contract: 群自动回复时直接读取事件顶层 `conversation_id`
#### `dws event consume user_im_message_receive_user`
**event_event_consume_user_001**
- Prompt: 监听 userId test-user-001 发给我的消息,包括单聊和群聊
- Expected: `dws event consume user_im_message_receive_user --user test-user-001 -f ndjson`
- Flags: `--user` = `test-user-001`
**event_event_consume_user_open_id_001**
- Prompt: 监听 openDingtalkId abc 发给我的消息,包括单聊和群聊
- Expected: `dws event consume user_im_message_receive_user --open-dingtalk-id abc -f ndjson`
- Flags: `--open-dingtalk-id` = `abc`
#### `dws event consume user_im_message_read_o2o`
**event_event_consume_read_o2o_001**
- Prompt: 监听我发给 userId test-user-001 的单聊消息是否已读
- Expected: `dws event consume user_im_message_read_o2o --user test-user-001 -f ndjson`
- Flags: `--user` = `test-user-001`
- Contract: 从每行 NDJSON 顶层读取 `message_id`、`reader`、`reader_open_dingtalk_id`、`read_time`
#### `dws event consume user_im_message_read_group`
**event_event_consume_read_group_001**
- Prompt: 监听 openConversationId cid123 群里我发的消息是否已读
- Expected: `dws event consume user_im_message_read_group --group cid123 -f ndjson`
- Flags: `--group` = `cid123`
- Contract: 从每行 NDJSON 顶层读取 `conversation_id`、`reader`、`reader_open_dingtalk_id`、`read_time`
#### `dws event consume user_im_message_recall_o2o`
**event_event_consume_recall_o2o_001**
- Prompt: 监听我和 userId test-user-001 的单聊消息撤回事件
- Expected: `dws event consume user_im_message_recall_o2o --user test-user-001 -f ndjson`
- Flags: `--user` = `test-user-001`
- Contract: 从每行 NDJSON 顶层读取 `message_id`、`recaller`、`recaller_open_dingtalk_id`、`recall_time`
#### `dws event consume user_im_message_recall_group`
**event_event_consume_recall_group_001**
- Prompt: 监听 openConversationId cid123 的群消息撤回事件
- Expected: `dws event consume user_im_message_recall_group --group cid123 -f ndjson`
- Flags: `--group` = `cid123`
- Contract: 从每行 NDJSON 顶层读取 `conversation_id`、`recaller`、`recaller_open_dingtalk_id`、`recall_time`
#### `dws event consume user_im_message_reaction_o2o`
**event_event_consume_reaction_o2o_001**
- Prompt: 监听我和 userId test-user-001 的单聊消息贴表情事件
- Expected: `dws event consume user_im_message_reaction_o2o --user test-user-001 -f ndjson`
- Flags: `--user` = `test-user-001`
- Contract: 从每行 NDJSON 顶层读取 `operator`、`operator_open_dingtalk_id`、`reaction_name`、`operation_type`
**event_event_consume_reaction_o2o_open_id_001**
- Prompt: 监听我和 openDingtalkId abc 的单聊消息贴表情事件
- Expected: `dws event consume user_im_message_reaction_o2o --open-dingtalk-id abc -f ndjson`
- Flags: `--open-dingtalk-id` = `abc`
- Contract: 从每行 NDJSON 顶层读取 `operator`、`operator_open_dingtalk_id`、`reaction_name`、`operation_type`
#### `dws event consume user_im_message_reaction_group`
**event_event_consume_reaction_group_001**
- Prompt: 监听 openConversationId cid123 的群消息表情回应事件
- Expected: `dws event consume user_im_message_reaction_group --group cid123 -f ndjson`
- Flags: `--group` = `cid123`
- Contract: 从每行 NDJSON 顶层读取 `conversation_id`、`operator`、`reaction_name`、`reaction_text`、`operation_type`
#### `dws chat search`
+2 -2
View File
@@ -1424,8 +1424,8 @@
**event_event_consume_o2o_001** ✅ PASS
- Prompt: 监听我和 userId 507971 的单聊消息
- Expected: `dws event consume user_im_message_receive_o2o --user 507971 -f ndjson`
- Prompt: 监听我和 userId test-user-001 的单聊消息
- Expected: `dws event consume user_im_message_receive_o2o --user test-user-001 -f ndjson`
- Skill Reference: references/products/event.md
- Command path: PASS (event consume user_im_message_receive_o2o)
- Flags: PASS (1 flags validated)
+45
View File
@@ -76,6 +76,51 @@ func TestSkillDocsDoNotRecommendRetiredCommands(t *testing.T) {
}
}
func TestEventSkillUsesFlatOutputContract(t *testing.T) {
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller(0) failed")
}
root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
paths := []string{
filepath.Join(root, "skills", "multi", "dingtalk-event", "SKILL.md"),
filepath.Join(root, "skills", "multi", "dingtalk-event", "references", "event-im.md"),
filepath.Join(root, "skills", "mono", "references", "products", "event.md"),
}
for _, path := range paths {
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
text := string(content)
for _, required := range []string{
"[event] ready",
"conversation_id",
"sender_open_dingtalk_id",
"reader_open_dingtalk_id",
"recaller_open_dingtalk_id",
"reaction_name",
"operation_type",
"dws chat message download-media",
"--open-dingtalk-id",
} {
if !strings.Contains(text, required) {
t.Errorf("%s missing event contract %q", path, required)
}
}
for _, retired := range []string{
".data | fromjson",
"payload.body.",
"尚无稳定业务样本",
"暂无稳定 payload schema",
} {
if strings.Contains(text, retired) {
t.Errorf("%s still documents retired event path %q", path, retired)
}
}
}
}
func hasAny(s string, needles []string) bool {
for _, needle := range needles {
if strings.Contains(s, needle) {