Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ed5de2374 | ||
|
|
987c63d99c | ||
|
|
e565746fb7 | ||
|
|
4f76d7cb4c | ||
|
|
e94f230236 | ||
|
|
5242a0e1b1 | ||
|
|
c3e57b874b | ||
|
|
fa558372d2 | ||
|
|
ec9ae33a43 | ||
|
|
2b49a2f365 | ||
|
|
ae9b14e536 | ||
|
|
996c4ab250 | ||
|
|
cf36ccb46e | ||
|
|
361115956f | ||
|
|
91bd7c7802 | ||
|
|
5a8376ac0f | ||
|
|
31c984e18c | ||
|
|
69c0eb1a49 | ||
|
|
82e98d98a3 | ||
|
|
8a7e1c7be7 | ||
|
|
f59be6c19a | ||
|
|
c7c9a6f926 | ||
|
|
a0224e1cbd | ||
|
|
cd22cfb530 | ||
|
|
adc528c206 | ||
|
|
a2f1e79603 | ||
|
|
1adb4bc681 | ||
|
|
723c577484 | ||
|
|
766930f6e7 | ||
|
|
2e3311c955 | ||
|
|
1b4bb6b498 | ||
|
|
368e439280 | ||
|
|
8965fd2707 | ||
|
|
c0a7ad88a4 | ||
|
|
b62b1848aa | ||
|
|
5dd7f9abd3 | ||
|
|
eefee3c063 | ||
|
|
6f5d17335b |
+739
-143
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
- **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.
|
||||
- **Tag-push publication after the recovery refactor** — the guarded-recovery jobs are skipped on ordinary tag pushes, and the downstream build/sign/publish jobs relied on an implicit `success()` that treats those skipped ancestors as a chain failure, so pushing an annotated release tag validated the contract but silently skipped every publish job. The `release`, `verify-darwin-signatures`, `publish-release`, `publish-channels`, and `mirror-gitee-release` gates now use an explicit `!cancelled()` status check alongside their existing `result == 'success'` conditions, restoring direct tag-push delivery while keeping protected recovery as the failure break-glass.
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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", "",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 查询/发送命令"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
} {
|
||||
|
||||
@@ -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, "ed); 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 ""
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>] [--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,46 @@ 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=""
|
||||
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
|
||||
;;
|
||||
--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
|
||||
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
|
||||
|
||||
Executable
+153
@@ -0,0 +1,153 @@
|
||||
#!/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=""
|
||||
EXPECTED_REPOSITORY="DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: recover-release.sh <version> --remote <name> [--failed-run <run-id>]
|
||||
|
||||
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 ;;
|
||||
-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
|
||||
command -v gh >/dev/null 2>&1 || { printf '%s\n' 'gh is required for release recovery' >&2; exit 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
|
||||
failed_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\" and (.conclusion as \\$c | [\"failure\", \"cancelled\", \"timed_out\", \"startup_failure\", \"stale\"] | index(\\$c))) | .id"
|
||||
)" || {
|
||||
printf 'could not query failed Release runs for %s\n' "$VERSION" >&2
|
||||
exit 1
|
||||
}
|
||||
FAILED_RUN_ID="$(printf '%s\n' "$failed_runs" | sed -n '1p')"
|
||||
[ -n "$FAILED_RUN_ID" ] || {
|
||||
printf 'no failed exact-tag Release run found for %s at %s\n' "$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\n' "$EXPECTED_REPOSITORY" "$FAILED_RUN_ID"
|
||||
[ -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_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
@@ -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,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" \
|
||||
|
||||
Executable
+254
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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(待办任务)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`,排查后去掉。
|
||||
- 需要长期运行:交给外部进程管理;不要把消息历史查询写成轮询脚本。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -598,6 +598,203 @@ 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 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_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"`,
|
||||
`["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 TestReleaseWorkflowUploadsPostProcessedDarwinAssets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -762,7 +959,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:], `gh release edit "$RELEASE_VERSION" --draft=false`)
|
||||
if codesign == -1 || publish == -1 {
|
||||
t.Fatal("macOS codesign verification and explicit Draft publication are required")
|
||||
}
|
||||
@@ -824,14 +1021,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 +1056,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 +1099,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)
|
||||
|
||||
@@ -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")
|
||||
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\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...)...)
|
||||
|
||||
@@ -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{
|
||||
@@ -392,6 +395,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 +1071,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 +1653,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 +1676,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) {
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user