Compare commits

...
Author SHA1 Message Date
修雨 d5dbefb27e docs: sync outdated references and add audit-log guide
- architecture.md: rewrite to reflect current plugin-based structure
  (removed market/discovery/ir/generator/compat, added helpers/plugin/audit/pat)
- automation.md: update task routing and repository map to current packages
- command-index.md: fix source citation (internal/plugin replaces removed compat pipeline)
- connect-daemon-service.md: update command path from 'dws devapp robot connect'
  to 'dws dev connect' (7 occurrences + launchd plist args)
- audit-log.md: new doc covering event format, env vars, CLI commands,
  hash chain verification, remote forwarding, and redaction levels
2026-07-06 15:38:57 +08:00
修雨 1c1abf3069 style(helpers): fix gofmt formatting for devapp_connect and connect_daemon
Import ordering and struct field alignment were off since #548,
causing the CI format check to fail on main.
2026-07-06 15:25:24 +08:00
Evilsong7and南凇 043881e0e3 新增企业凭证hook&对应凭证登录检验未通过的报错处理 (#506)
Co-authored-by: 南凇 <tianjinsong.tjs@alibaba-inc.com>
2026-07-06 14:54:27 +08:00
修雨 b63e1b4469 release: cut 1.0.47 — connector supervision + bot-to-bot @-mention (#548) 2026-07-06 00:46:47 +08:00
50 changed files with 3343 additions and 194 deletions
+2
View File
@@ -42,3 +42,5 @@ dws.zip
# 功能测试运行产物 # 功能测试运行产物
results.jsonl results.jsonl
test/dev_functional/results.jsonl test/dev_functional/results.jsonl
.vercel
.env*
+30
View File
@@ -6,6 +6,36 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased] ## [Unreleased]
## [1.0.47] - 2026-07-05
This release adds **connector supervision & health monitoring** (`dev connect list/status/restart/stop`) and fixes **bot-to-bot @-mention** delivery end-to-end.
### Added
- **`dev connect list`** — PM2-style colored table enumerating all local connectors with state (healthy / degraded / down / not_running), PID, channel, and uptime.
- **`dev connect status`** — panel view with heartbeat, last recv timestamp, session webhook age, and `--json` for external monitoring.
- **`dev connect restart`** — restarts a daemon via persisted `daemon-state.json` (unified-app-id credential fetch, no local secret storage).
- **`dev connect stop`** — graceful SIGTERM shutdown releasing the single-instance lock and Stream connection.
- **Health watchdog** — background goroutine writes `heartbeat.json`; `status`/`list` derive state from heartbeat freshness + process liveness + pid-reuse detection.
- **`--alwayson` flag** — opt-in auto-restart: supervisor relaunches the worker on crash (requires `--daemon`).
- **`--notify-staff-id`** — state-change notifications (start / stop / crash) sent as DingTalk messages to the specified staffId.
- **`--unified-app-id` credential flow for `dev connect`** — fetches clientId/clientSecret at startup via `dev app credentials get`, keeping secrets off the command line and out of `daemon-state.json`.
- **API-sent file download** (`feat(connect): download API-sent files via storage v2 API`) — file messages sent via `dws chat message send --msg-type file --dentry-id --space-id` are now downloaded by the connector through the storage v2 `getDownloadInfo` API (dentryId + spaceId → presigned URL → local temp file), so file-based Q&A works regardless of how the file was sent.
- **`--at-open-dingtalk-ids` for `chat message send-by-bot`** — @-mention bots or cross-org users by openDingTalkId in group messages.
### Fixed
- **Bot-to-bot @-mention send side** — `atOpendingtalkIds` (the server's lowercase spelling) is now used instead of the camelCase `atOpenDingTalkIds` which was silently ignored. The unnecessary `openDingTalkId → userId` reverse lookup (always failed for bots) is removed; the id is forwarded verbatim.
- **Bot-to-bot @-mention receive side** — `interactiveCard` messages (how DingTalk delivers a bot @-mentioning another bot) are now parsed: `extractInteractiveCardText` flattens `cardContent[].children[].value` leaves and strips the leading @-mention by leaf boundary. The `emotion/reply` reaction (which 500s on bot-sent cards) is skipped for `interactiveCard` turns.
- **Markdown/richText body extraction** — `extractCallbackText` gains a `cardContent` fallback so structured-text messages are no longer silently dropped.
- **Send-by-bot @ chip rendering** — `<@id>` placeholders in the markdown body are rewritten to `@id` for both userIds and openDingTalkIds so the mention chip renders in all cases.
- **Connector retry on transient network errors** — `sendBySession` retries on transient failures instead of dropping the reply.
- **Orphan worker cleanup & watchdog deadlock** — stale workers from a crashed supervisor are detected and cleaned; a channel-capacity fix prevents the watchdog from blocking.
- **Idle connector false-down** — heartbeat ticker now advances `updatedUnix` so a connector with no inbound traffic is not marked degraded.
- **FD limit check** — `checkFDLimit` split into platform files for Windows cross-compilation.
- **Default agent timeout removed** — no timeout by default (was incorrectly defaulting to a low value).
- **keepAlive shortened to 30 µs** — aligns with Stream SDK expectations; adds `ulimit` check for multi-agent stability.
## [1.0.46] - 2026-07-01 ## [1.0.46] - 2026-07-01
### Fixed ### Fixed
+6 -1
View File
@@ -526,9 +526,14 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
`dws dev connect` bridges a DingTalk robot to a local AI CLI (Claude Code / Codex / opencode / Qoder / Gemini, or any tool via `--agent-cmd`): @-mention the bot in a chat and it answers using your local agent, keeping per-conversation multi-turn memory. `dws dev connect` bridges a DingTalk robot to a local AI CLI (Claude Code / Codex / opencode / Qoder / Gemini, or any tool via `--agent-cmd`): @-mention the bot in a chat and it answers using your local agent, keeping per-conversation multi-turn memory.
```bash ```bash
dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <secret> dws dev connect --channel auto --unified-app-id <unifiedAppId>
``` ```
> `--unified-app-id` resolves `clientSecret` at runtime via `dev app credentials get`,
> so the secret never appears in argv (`ps` / journald / shell history). The
> legacy `--robot-client-id <id> --robot-client-secret <secret>` still works but
> the CLI will warn you.
In-chat **session commands** (send the bare command as the whole message — no agent turn, no tokens): In-chat **session commands** (send the bare command as the whole message — no agent turn, no tokens):
| Command | Effect | | Command | Effect |
+31 -16
View File
@@ -4,23 +4,38 @@
## High-Level Flow ## High-Level Flow
1. `internal/market` fetches the registry and server metadata. 1. `cmd` is the CLI entrypoint, invoking `internal/app` to build the root Cobra command tree.
2. `internal/discovery` resolves runtime server capabilities and caches results. 2. `internal/app` wires static utility commands (`auth`, `audit`, `schema`, `completion`) and dynamically loads product commands via `internal/plugin`.
3. `internal/ir` normalizes discovery output into one canonical tool catalog. 3. `internal/helpers` contains the main command handlers for all product surfaces (`dev`, `chat`, `calendar`, `contact`, `aitable`, etc.).
4. `internal/cli` and `internal/app` mount that catalog into the public Cobra command tree. 4. `internal/executor` and `internal/transport` execute MCP JSON-RPC calls; `internal/output` formats responses.
5. `internal/transport` executes MCP JSON-RPC calls and `internal/output` formats responses. 5. `internal/auth` manages login state, PAT tokens, and agent-code detection.
## Repository Structure ## Repository Structure
- `cmd`: CLI entrypoint - `cmd`: CLI entrypoint
- `internal/app`: root command wiring and static utility commands - `internal/app`: root command wiring, static utility commands, and plugin loading
- `internal/discovery`, `internal/market`, `internal/transport`: runtime discovery and execution - `internal/helpers`: product command handlers (dev, chat, calendar, contact, etc.)
- `internal/ir`: canonical intermediate representation for discovered tools - `internal/plugin`: plugin-based dynamic command loader
- `internal/generator`: docs, schema, and skill generation pipeline - `internal/cli`: catalog types and endpoint loader (static endpoint mode)
- `internal/compat`, `internal/helpers`: legacy-compatible overlays and helper commands - `internal/executor`: invocation dispatch and result handling
- `skills/`: bundled agent skills source and generated skill docs - `internal/transport`: MCP HTTP client and request signing
- `test/`: CLI, compatibility, integration, contract, and script tests - `internal/auth`: login, token management, agent-code detection, identity
- `internal/audit`: user operation audit log (JSONL, hash chain, forwarding)
## Public Repository Contract - `internal/errors`: structured error model with categories and hints
- `internal/keychain`: OS keychain integration for credential storage
This repository ships source, docs, tests, packaging templates, and install scripts. Generated or release-only artifacts are produced by repository scripts and are not required to exist in a clean checkout unless explicitly committed as part of a release workflow. - `internal/security`: endpoint allowlist and domain trust
- `internal/safety`: runtime safety checks (confirm prompts, dry-run guards)
- `internal/cobracmd`: shared Cobra command builders
- `internal/pat`: PAT (Personal Access Token) authorization flow
- `internal/output`: response formatting (json, table, raw, pretty)
- `internal/logging`: structured logging and argument sanitization
- `internal/tui`: terminal UI helpers
- `internal/recovery`: panic recovery and graceful degradation
- `pkg/configmeta`: environment variable registry and documentation
- `pkg/config`: configuration constants and paths
- `pkg/edition`: edition detection (oss vs enterprise)
- `pkg/mcptypes`: MCP protocol type definitions
- `skills/`: bundled agent skills (mono/ and multi/ layouts)
- `test/`: CLI, integration, contract, unit, and skill E2E tests
- `scripts/`: install scripts, policy checks, and CI helpers
- `envelope/`: pre-built discovery payloads for offline use
+227
View File
@@ -0,0 +1,227 @@
# 审计日志(Audit Log)
DWS 自动记录每次 MCP HTTP 调用的审计事件,用于合规追溯和安全审查。默认启用,无需额外配置。
## 功能特性
- **自动记录**:每次命令执行产生一条 JSONL 审计事件
- **按天轮转**:日志文件按日期分割(`audit-YYYYMMDD.jsonl`),默认留存 90 天
- **防篡改**:L1 哈希链(sha256),每条事件链接前一条的 hash,可验证完整性
- **远端转发**:支持 POST 到外部 SIEM 或审计平台
- **三级脱敏**:转发时可按 none/hashed/minimal 脱敏敏感字段
- **CLI 命令**:内置 `dws audit tail/export/verify` 查看和验证
## 环境变量
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `DWS_AUDIT` | 启用 | 设 `0`/`false`/`off` 关闭审计 |
| `DWS_AUDIT_DIR` | `~/.dws/audit` | 审计日志目录 |
| `DWS_AUDIT_RETENTION_DAYS` | `90` | 日志留存天数 |
| `DWS_AUDIT_FORWARD_URL` | (空) | 远端转发 URL(POST JSON) |
| `DWS_AUDIT_FORWARD_TOKEN` | (空) | 远端转发 Bearer Token |
| `DWS_AUDIT_FORWARD_REDACT` | `none` | 转发脱敏级别:`none`/`hashed`/`minimal` |
## 审计事件格式
每条事件是一行 JSON,字段如下:
```json
{
"ts": "2026-07-06T10:59:06+08:00",
"execution_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"agent_id": "agent-xxx",
"actor": {
"user_id": "525018",
"name": "胡奕舟",
"corp_id": "ding8196cd9a2b2405da24f2f5cc6abecb85",
"corp_name": "钉钉"
},
"product": "calendar",
"command": "list_events",
"endpoint": "https://api.dingtalk.com/v1.0/calendar/users/xxx/calendars/primary/events",
"params_summary": "maxResults=20",
"result": "success",
"error_category": "",
"error_reason": "",
"duration_ms": 234,
"cli_version": "1.0.47",
"os": "darwin",
"arch": "arm64",
"prev_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
}
```
**字段说明**:
- `ts`:事件时间戳(RFC3339)
- `execution_id`:本次命令执行的唯一 ID
- `agent_id`:Agent 标识(若有)
- `actor`:执行者信息(从登录态获取)
- `product`:调用的产品(如 `calendar`、`chat`、`contact`)
- `command`:调用的命令(如 `list_events`、`send_message`)
- `endpoint`:实际请求的 API 端点(已脱敏)
- `params_summary`:参数摘要(已脱敏)
- `result`:`success` 或 `error`
- `error_category` / `error_reason`:错误分类和原因(成功时为空)
- `duration_ms`:命令执行耗时(毫秒)
- `prev_hash` / `hash`:哈希链字段,用于防篡改验证
## CLI 命令
### 查看最近日志
```bash
# 查看最近 20 条审计事件(默认)
dws audit tail
# 查看最近 50 条
dws audit tail -n 50
```
输出示例:
```
2026-07-06 10:59:06 calendar list_events user=525018 result=success 234ms
2026-07-06 10:58:42 chat send_message user=525018 result=success 156ms
2026-07-06 10:57:15 contact search user=525018 result=success 89ms
```
### 导出日志
```bash
# 导出最近 7 天的 JSONL
dws audit export --since 2026-06-29 --until 2026-07-06 --format jsonl > audit-7d.jsonl
# 导出为 CSV(方便 Excel 打开)
dws audit export --since 2026-07-01 --format csv > audit-july.csv
```
### 验证哈希链
```bash
# 验证当前最新日志文件的哈希链完整性
dws audit verify
# 验证指定文件
dws audit verify --file ~/.dws/audit/audit-20260706.jsonl
```
输出:
```
✓ 哈希链完整:234 条事件全部校验通过
```
或:
```
✗ 哈希链断裂:第 156 条事件的 hash 不匹配
```
## 哈希链防篡改
每条事件的 `hash` 字段由以下公式计算:
```
hash = sha256(prev_hash + event_json_without_hash_fields)
```
- 首条事件的 `prev_hash` 为空字符串
- 后续事件的 `prev_hash` = 前一条的 `hash`
- 任何对历史事件的篡改都会导致后续所有 hash 失效
**验证流程**:
1. 读取日志文件,逐行解析
2. 对每条事件,移除 `prev_hash` 和 `hash` 字段,重新序列化
3. 用前一条的 hash + 当前事件 JSON 计算新 hash
4. 对比计算结果与文件中记录的 hash
5. 全部匹配 = 完整;某条不匹配 = 被篡改
## 远端转发
设置 `DWS_AUDIT_FORWARD_URL` 后,每条审计事件会异步 POST 到指定端点:
```bash
export DWS_AUDIT_FORWARD_URL=https://siem.example.com/ingest/audit
export DWS_AUDIT_FORWARD_TOKEN=your-bearer-token # 可选
```
**请求格式**:
```http
POST /ingest/audit HTTP/1.1
Host: siem.example.com
Authorization: Bearer your-bearer-token
Content-Type: application/json
{"ts":"2026-07-06T10:59:06+08:00","product":"calendar",...}
```
**超时与重试**:3 秒超时,失败不阻塞命令执行(best-effort),不自动重试。
## 脱敏分级
转发时可通过 `DWS_AUDIT_FORWARD_REDACT` 控制脱敏级别:
| 级别 | 行为 | 适用场景 |
|------|------|----------|
| `none`(默认) | 原样转发,不脱敏 | 内部审计平台 |
| `hashed` | actor.name 哈希化,params_summary 脱敏 | 跨部门共享 |
| `minimal` | 仅保留 ts/product/command/result/duration_ms,移除 actor/endpoint/params | 对外合规报告 |
**示例**:
```bash
# 最小化脱敏(仅保留元数据)
export DWS_AUDIT_FORWARD_REDACT=minimal
export DWS_AUDIT_FORWARD_URL=https://compliance.example.com/audit
```
## 常见问题
### Q: 审计日志占多少磁盘?
典型场景(每天 100 条命令)约 50KB/天,90 天约 4.5MB。日志文件是 JSONL 纯文本,gzip 压缩后约 1/5。
### Q: 关闭审计会影响性能吗?
设置 `DWS_AUDIT=0` 后,审计模块不初始化,零开销。默认启用时,每条事件写入耗时 <1ms(异步磁盘 IO)。
### Q: 哈希链断了怎么办?
可能原因:
1. 手动编辑过日志文件
2. 磁盘损坏
3. 并发写入导致顺序错乱(罕见)
**处理**:
- 备份当前日志
- 用 `dws audit verify` 定位断裂位置
- 从断裂点之后的事件可继续验证(前缀已不可信)
### Q: 如何清理旧日志?
自动清理:`DWS_AUDIT_RETENTION_DAYS=90`(默认),超过 90 天的文件在下次启动时 best-effort 删除。
手动清理:
```bash
# 删除 2026 年 6 月之前的日志
rm ~/.dws/audit/audit-202605*.jsonl
```
## 技术实现
- **核心包**:`internal/audit`
- **集成点**:`internal/app/runner.go` 的 `executeInvocation` 方法(defer 调用 `emitAudit`)
- **身份获取**:从 `auth.LoadTokenData` 读取当前登录用户
- **参数脱敏**:调用 `logging.SanitizeArguments`(与现有日志脱敏逻辑一致)
## 相关文档
- [环境变量参考](./reference.md)
- [架构概览](./architecture.md)
- [自动化与脚本](./automation.md)
+26 -25
View File
@@ -13,40 +13,44 @@ repository root while preserving repo-local guidance for automation.
## Project Snapshot ## Project Snapshot
- `dws` is a Go-based DingTalk Workspace CLI and MCP runtime bridge. - `dws` is a Go-based DingTalk Workspace CLI and MCP runtime bridge.
- One internal Tool IR drives canonical CLI, schema, docs, skills, and snapshots. - Product commands are loaded dynamically via `internal/plugin` from bundled descriptors.
- Compatibility and helper surfaces are overlays, not the canonical truth. - Command handlers live in `internal/helpers`; runtime execution flows through `internal/executor` and `internal/transport`.
## Repository Map ## Repository Map
- `cmd`: public CLI entrypoint - `cmd`: public CLI entrypoint
- `internal/app`: root command wiring and command tree mount points - `internal/app`: root command wiring, static utility commands, plugin loading
- `internal/discovery`, `internal/market`, `internal/transport`: runtime discovery and MCP transport - `internal/helpers`: product command handlers (dev, chat, calendar, contact, etc.)
- `internal/generator`: CLI/schema/docs/skills generation pipeline - `internal/plugin`: plugin-based dynamic command loader
- `internal/compat`, `internal/helpers`: legacy-compatible aliases and helper commands - `internal/cli`: catalog types and static endpoint loader
- `internal/executor`: invocation dispatch and result handling
- `internal/transport`: MCP HTTP client and request signing
- `internal/auth`: login, token management, agent-code detection
- `internal/audit`: user operation audit log
- `internal/errors`: structured error model with categories and hints
- `internal/keychain`: OS keychain integration for credential storage
- `internal/security`: endpoint allowlist and domain trust
- `internal/pat`: PAT (Personal Access Token) authorization flow
- `docs/`: public architecture and reference docs - `docs/`: public architecture and reference docs
- `hack/`: developer-only helper commands not shipped as public binaries
- `scripts/`: build, test, lint, packaging, and policy checks - `scripts/`: build, test, lint, packaging, and policy checks
- `test/`: integration, contract, compatibility, and script validation suites - `test/`: CLI, integration, contract, unit, and skill E2E test suites
## Task Routing ## Task Routing
- Add or fix a command path: start from `internal/app` and the related module under `internal/*` - Add or fix a command path: start from `internal/helpers` (handler implementations) or `internal/app` (command tree wiring)
- Discovery or protocol issues: inspect `internal/discovery`, `internal/market`, `internal/transport` - Protocol or transport issues: inspect `internal/transport`
- Generated output drift: inspect `internal/generator` and run drift checks - Auth or login issues: inspect `internal/auth`, `internal/pat`, `internal/keychain`
- Legacy behavior mismatch: inspect `internal/compat` and `test/cli_compat` - Error message or category issues: inspect `internal/errors`
- Failure or degraded mode: inspect `internal/discovery`, `internal/errors` - Audit log issues: inspect `internal/audit`
- Plugin loading or command surface: inspect `internal/plugin`
- Failure or degraded mode: inspect `internal/errors`, `internal/recovery`
## Generated Artifacts ## Policy Checks
Prefer editing source logic instead of generated files directly. When command surface or plugin descriptors change, run:
- Generated-heavy paths: - `./scripts/policy/check-command-surface.sh --strict`
- `docs/generated/` - `./scripts/policy/check-open-source-assets.sh`
- `skills/generated/`
- `test/golden/generated_outputs/`
- When generator or command surface changes, run:
- `./scripts/policy/check-generated-drift.sh`
- `./scripts/policy/check-command-surface.sh --strict`
## Common Commands ## Common Commands
@@ -55,9 +59,6 @@ make build
make test make test
make lint make lint
./scripts/dev/ci-local.sh ./scripts/dev/ci-local.sh
./scripts/policy/check-generated-drift.sh
./scripts/policy/check-command-surface.sh --strict
./scripts/policy/check-open-source-assets.sh
git diff --check git diff --check
``` ```
+2 -3
View File
@@ -2,12 +2,11 @@
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration. Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
- **Source**: `dws-wukong/envelope/channel/open/pre/config.json`
- **Products**: 13 - **Products**: 13
- **Total commands**: 160 - **Total commands**: 160
- **Generated from**: `internal/compat.BuildDynamicCommands` rendering of the pre config — the same code path the CLI uses at runtime. - **Generated from**: `internal/plugin` command descriptors — the same code path the CLI uses at runtime.
> Auto-generated. Edit `pre/config.json`, not this file. > Auto-generated. Update plugin descriptors in `internal/plugin/`, not this file.
## Global flags ## Global flags
+40 -20
View File
@@ -1,20 +1,28 @@
# Running the connector as a 7x24 service # Running the connector as a 7x24 service
`dws devapp robot connect` keeps a DingTalk robot wired to a local agent over a `dws dev connect` keeps a DingTalk robot wired to a local agent over a
Stream long-connection. By default it runs in the foreground and dies when the Stream long-connection. By default it runs in the foreground and dies when the
terminal closes. For an unattended "digital employee" you have two options. terminal closes. For an unattended "digital employee" you have two options.
> **Security**: prefer `--unified-app-id <uappid>` over
> `--robot-client-id/--robot-client-secret`. With `--unified-app-id` the CLI
> resolves clientId/clientSecret at runtime through `dev app credentials get`,
> so the secret never appears in `ps` / journald / shell history. Pasting
> `--robot-client-secret` onto argv lets any local user read your AppSecret
> with `ps -ef`; the CLI will warn you when you do that.
## Option A: built-in daemon (recommended for a quick start) ## Option A: built-in daemon (recommended for a quick start)
```bash ```bash
# Detach into a background supervisor that restarts the connector if it crashes. # Detach into a background supervisor that restarts the connector if it crashes.
dws devapp robot connect --daemon \ dws dev connect --daemon \
--channel claudecode \ --channel claudecode \
--robot-client-id <clientId> --robot-client-secret <clientSecret> --unified-app-id <unifiedAppId>
# Inspect / stop it. # Inspect / stop / restart it (locate the daemon by unifiedAppId).
dws devapp robot connect status --robot-client-id <clientId> dws dev connect status --unified-app-id <unifiedAppId>
dws devapp robot connect stop --robot-client-id <clientId> dws dev connect stop --unified-app-id <unifiedAppId>
dws dev connect restart --unified-app-id <unifiedAppId>
``` ```
- The parent prints the daemon pid and the log path, then exits. - The parent prints the daemon pid and the log path, then exits.
@@ -23,9 +31,9 @@ dws devapp robot connect stop --robot-client-id <clientId>
10 consecutive fast failures) when it exits abnormally. 10 consecutive fast failures) when it exits abnormally.
- The single-instance lock (one connector per robot per machine) is reused, so a - The single-instance lock (one connector per robot per machine) is reused, so a
duplicate daemon refuses to start. duplicate daemon refuses to start.
- Logs go to `~/.dws/connect/<clientId>/daemon.log` with size-based rotation - Logs go to `~/.dws/connect/<key>/daemon.log` with size-based rotation
(5 MB x 2 backups), and the pid file lives at (5 MB x 2 backups), and the pid file lives at
`~/.dws/connect/<clientId>/daemon.pid`. `~/.dws/connect/<key>/daemon.pid`.
- The daemon does NOT survive a reboot. For that, use Option B. - The daemon does NOT survive a reboot. For that, use Option B.
> Windows: `--daemon` is not supported (no `setsid` / POSIX signal stop). Use a > Windows: `--daemon` is not supported (no `setsid` / POSIX signal stop). Use a
@@ -39,7 +47,7 @@ restart it. This is the most robust way to get boot-time auto-start.
### macOS — launchd ### macOS — launchd
Save as `~/Library/LaunchAgents/com.dingtalk.dws.connect.plist`, edit the paths Save as `~/Library/LaunchAgents/com.dingtalk.dws.connect.plist`, edit the paths
and credentials, then `launchctl load -w <path>`. and `REPLACE_UNIFIED_APP_ID`, then `launchctl load -w <path>`.
```xml ```xml
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
@@ -52,15 +60,12 @@ and credentials, then `launchctl load -w <path>`.
<key>ProgramArguments</key> <key>ProgramArguments</key>
<array> <array>
<string>/usr/local/bin/dws</string> <string>/usr/local/bin/dws</string>
<string>devapp</string> <string>dev</string>
<string>robot</string>
<string>connect</string> <string>connect</string>
<string>--channel</string> <string>--channel</string>
<string>claudecode</string> <string>claudecode</string>
<string>--robot-client-id</string> <string>--unified-app-id</string>
<string>REPLACE_CLIENT_ID</string> <string>REPLACE_UNIFIED_APP_ID</string>
<string>--robot-client-secret</string>
<string>REPLACE_CLIENT_SECRET</string>
</array> </array>
<key>RunAtLoad</key> <key>RunAtLoad</key>
<true/> <true/>
@@ -86,8 +91,8 @@ itself relies on the single-instance lock to avoid duplicates.
### Linux — systemd (user service) ### Linux — systemd (user service)
Save as `~/.config/systemd/user/dws-connect.service`, edit paths/credentials, Save as `~/.config/systemd/user/dws-connect.service`, edit paths and
then: `REPLACE_UNIFIED_APP_ID`, then:
```bash ```bash
systemctl --user daemon-reload systemctl --user daemon-reload
@@ -104,10 +109,9 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
ExecStart=/usr/local/bin/dws devapp robot connect \ ExecStart=/usr/local/bin/dws dev connect \
--channel claudecode \ --channel claudecode \
--robot-client-id REPLACE_CLIENT_ID \ --unified-app-id REPLACE_UNIFIED_APP_ID
--robot-client-secret REPLACE_CLIENT_SECRET
Restart=always Restart=always
RestartSec=5 RestartSec=5
# Optional hardening: # Optional hardening:
@@ -121,6 +125,22 @@ WantedBy=default.target
`Restart=always` + `RestartSec` gives crash recovery; systemd captures stdout/ `Restart=always` + `RestartSec` gives crash recovery; systemd captures stdout/
stderr into the journal (`journalctl --user -u dws-connect -f`). stderr into the journal (`journalctl --user -u dws-connect -f`).
## Legacy: passing clientId/clientSecret directly (not recommended)
If you truly must pass credentials on the command line (e.g. one-off local
debugging without a unifiedAppId), the CLI still accepts
`--robot-client-id <id> --robot-client-secret <secret>` and will print a
security warning to stderr. This form:
- exposes `clientSecret` to every user on the box via `ps -ef`;
- gets baked into launchd `ProgramArguments` / systemd `ExecStart`, which
makes rotation harder;
- means `dws dev connect restart` cannot re-fetch credentials — you
must re-run the full command yourself.
Prefer `--unified-app-id`. Only fall back to the pair when you understand the
trade-off.
## Which to choose ## Which to choose
- Just need it to outlive the terminal and self-heal on crash → `--daemon`. - Just need it to outlive the terminal and self-heal on crash → `--daemon`.
+3 -3
View File
@@ -108,9 +108,9 @@
- **期望(分步)**: - **期望(分步)**:
1. 新建:`robot submit --name <应用名> --robot-name 小助手 --desc <功能> --dry-run` → `--yes`(拿 taskId)→ 按 `intervalSeconds` 轮询 `robot result --task-id <taskId>`,只有 `SUCCESS` 才用返回 `robotCode/clientId/clientSecret`(敏感)。 1. 新建:`robot submit --name <应用名> --robot-name 小助手 --desc <功能> --dry-run` → `--yes`(拿 taskId)→ 按 `intervalSeconds` 轮询 `robot result --task-id <taskId>`,只有 `SUCCESS` 才用返回 `robotCode/clientId/clientSecret`(敏感)。
2. 现有应用:`robot get` 若 `robotStatus=UNCONFIGURED` → `robot config --unified-app-id <id> --name ... --mode STREAM --dry-run` → `--yes`(upsert 首次即创建)→ 回读 `robot get` 看 `robotStatus=ONLINE` → 需要时 `robot enable`(停用 `robot disable`)。 2. 现有应用:`robot get` 若 `robotStatus=UNCONFIGURED` → `robot config --unified-app-id <id> --name ... --mode STREAM --dry-run` → `--yes`(upsert 首次即创建)→ 回读 `robot get` 看 `robotStatus=ONLINE` → 需要时 `robot enable`(停用 `robot disable`)。
3. 建联:`dev connect --channel auto --robot-client-id x --robot-client-secret y --dry-run` 看出参 `cli` 字段做依赖预检;正式 connect 是前台长驻进程,对话里跑要后台运行并告诉用户怎么停,或引导自己开终端。 3. 建联:`dev connect --channel auto --unified-app-id UAID --dry-run` 看出参 `cli` 字段做依赖预检;正式 connect 是前台长驻进程,对话里跑要后台运行并告诉用户怎么停,或引导自己开终端。
- **通过判据**:走异步 submit/result(同步建号已下线),轮询到 SUCCESS 再用凭证;未配置时走 config 不是 enable;config 是 upsert;写后回读 `robotStatus`;建联先 dry-run 预检、处理好长驻/缺凭证(先 submit/result 建号)。 - **通过判据**:走异步 submit/result(同步建号已下线),轮询到 SUCCESS 再用凭证;未配置时走 config 不是 enable;config 是 upsert;写后回读 `robotStatus`;建联先 dry-run 预检、处理好长驻/缺凭证(先 submit/result 建号);默认用 `--unified-app-id` 建联而不是把 clientSecret 明文拼进命令行(避免被 `ps` 拉到)。
- **易错点**:找「同步一次建好」的命令;WAITING 就用凭证;robot info not exist 时去 enable;前台直接起 connect 卡住对话。 - **易错点**:找「同步一次建好」的命令;WAITING 就用凭证;robot info not exist 时去 enable;前台直接起 connect 卡住对话;把 clientSecret 直接怼到命令行上。
### C9. 事件订阅与上游错误排查 ### C9. 事件订阅与上游错误排查
- **用户说**:「让这个应用订阅『群成员入群』事件,订阅完看下当前订阅了哪些,再把它取消掉;对了我之前发版本报了个 errcode 62012,这是啥意思?」 - **用户说**:「让这个应用订阅『群成员入群』事件,订阅完看下当前订阅了哪些,再把它取消掉;对了我之前发版本报了个 errcode 62012,这是啥意思?」
+7 -4
View File
@@ -55,18 +55,21 @@ dws dev app robot submit --name 我的智能体 --robot-name 小助手 --desc "
dws dev app robot result --task-id 上一步返回的taskId --format json dws dev app robot result --task-id 上一步返回的taskId --format json
``` ```
`status` 变成 `SUCCESS` 后,返回结果里的 `clientId` 和 `clientSecret` **保存好**,下一步要用。 `status` 变成 `SUCCESS` 后,返回结果里的 `unifiedAppId` **记下来**,下一步要用。(`clientId` / `clientSecret` 也会返回,但下一步默认走 `unifiedAppId`,密钥由 dws 后台从 `credentials get` 自动拉取,你不需要手工复制密钥。)
## 第三步:把机器人接上你本地的 AI ## 第三步:把机器人接上你本地的 AI
```bash ```bash
dws dev connect --channel auto --robot-client-id dingxxxxxxxxxxxxxxxx --robot-client-secret yyyyyyyyyyyyyyyyyyyy dws dev connect --channel auto --unified-app-id 上一步的unifiedAppId
``` ```
- 把 `dingxxxxxxxxxxxxxxxx` 和 `yyyyyyyyyyyyyyyyyyyy` 换成第二步返回的 `clientId` 和 `clientSecret` 的实际值 - 把 `上一步的unifiedAppId` 换成第二步返回的 `unifiedAppId` 实际值
- 只用 `--unified-app-id`:`clientSecret` 由 `dws dev app credentials get` 后台取回,**不会出现在你的命令行**,不会被 `ps` 看到、不会留在 shell 历史里
- `--channel auto` 自动识别你电脑上装的 AI 工具(Claude Code / Codex / Qoder / Gemini 等) - `--channel auto` 自动识别你电脑上装的 AI 工具(Claude Code / Codex / Qoder / Gemini 等)
- 这个命令是前台运行的:窗口开着机器人在线,关掉窗口机器人下线 - 这个命令是前台运行的:窗口开着机器人在线,关掉窗口机器人下线
> 安全提示:老写法 `--robot-client-id <id> --robot-client-secret <secret>` 仍然能用,但 `clientSecret` 会以明文出现在命令行,任何本机用户 `ps -ef` 都能拉到;dws 会在 stderr 打一条 WARNING 提醒。除了没有 unifiedAppId 的老应用兜底之外,都建议改用 `--unified-app-id`。
## 第四步:拉进群聊 ## 第四步:拉进群聊
在钉钉里打开目标群: 在钉钉里打开目标群:
@@ -95,7 +98,7 @@ dws dev connect --channel auto --robot-client-id dingxxxxxxxxxxxxxxxx --robot-cl
```bash ```bash
dws dev connect \ dws dev connect \
--agent-cmd "你的AI命令 一次性问答参数" \ --agent-cmd "你的AI命令 一次性问答参数" \
--robot-client-id dingxxxx --robot-client-secret yyyy --unified-app-id 你的unifiedAppId
``` ```
机器人收到群消息后,会执行 `你的AI命令 一次性问答参数 "用户的问题"`(问题作为最后一个参数追加),把它打印出来的内容当作回复发回群里。 机器人收到群消息后,会执行 `你的AI命令 一次性问答参数 "用户的问题"`(问题作为最后一个参数追加),把它打印出来的内容当作回复发回群里。
+3
View File
@@ -923,6 +923,9 @@ func resolveIdentityHeaders() map[string]string {
if fn := edition.Get().MergeHeaders; fn != nil { if fn := edition.Get().MergeHeaders; fn != nil {
headers = fn(headers) headers = fn(headers)
} }
if fn := edition.Get().EnterpriseCredentialHeaders; fn != nil {
headers = fn(headers)
}
return headers return headers
} }
@@ -29,6 +29,13 @@ func TestClassifyDenialReason(t *testing.T) {
}, },
want: "channel_required", want: "channel_required",
}, },
{
name: "error ENTERPRISE_NOT_AUTHORIZED",
status: &CLIAuthStatus{
ErrorCode: "ENTERPRISE_NOT_AUTHORIZED",
},
want: "enterprise_not_authorized",
},
{ {
name: "error NO_AUTH", name: "error NO_AUTH",
status: &CLIAuthStatus{ status: &CLIAuthStatus{
+8
View File
@@ -276,6 +276,14 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请升级到最新版本的 CLI 后重试。")) _, _ = fmt.Fprintln(p.output(), i18n.T(" 请升级到最新版本的 CLI 后重试。"))
_, _ = fmt.Fprintln(p.output(), "") _, _ = fmt.Fprintln(p.output(), "")
return nil, errors.New(i18n.T("当前组织已开启渠道管控,请升级到最新版本的 CLI 后重试")) return nil, errors.New(i18n.T("当前组织已开启渠道管控,请升级到最新版本的 CLI 后重试"))
case "enterprise_not_authorized":
msg := i18n.T("本次请求未通过企业安全认证")
if authStatus != nil && strings.TrimSpace(authStatus.ErrorMsg) != "" {
msg = strings.TrimSpace(authStatus.ErrorMsg)
}
_, _ = fmt.Fprintln(p.output(), dfRed("⚠️ "+msg))
_, _ = fmt.Fprintln(p.output(), "")
return nil, errors.New(msg)
case "no_auth": case "no_auth":
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 认证已失效"))) _, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 认证已失效")))
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请执行 dws auth 重新登录。")) _, _ = fmt.Fprintln(p.output(), i18n.T(" 请执行 dws auth 重新登录。"))
+42
View File
@@ -0,0 +1,42 @@
// 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 auth
import (
"net/http"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// applyEditionEnterpriseCredentialHeaders injects overlay-provided enterprise
// credential headers (e.g. x-dws-enterprise-credential) into MCP control-plane
// and OAuth proxy requests.
func applyEditionEnterpriseCredentialHeaders(req *http.Request) {
if req == nil {
return
}
fn := edition.Get().EnterpriseCredentialHeaders
if fn == nil {
return
}
merged := fn(nil)
for k, v := range merged {
k = strings.TrimSpace(k)
v = strings.TrimSpace(v)
if k != "" && v != "" {
req.Header.Set(k, v)
}
}
}
+114
View File
@@ -18,6 +18,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
@@ -212,6 +213,7 @@ func (p *OAuthProvider) postJSON(ctx context.Context, endpoint string, body any)
return nil, fmt.Errorf("creating request: %w", err) return nil, fmt.Errorf("creating request: %w", err)
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
applyEditionEnterpriseCredentialHeaders(req)
client := p.httpClient client := p.httpClient
if client == nil { if client == nil {
@@ -1143,6 +1145,112 @@ const channelDeniedHTML = `<!doctype html>
</body> </body>
</html>` </html>`
const enterpriseDeniedHTML = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>钉钉 CLI</title>
<style>
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
padding: 20px;
}
.card {
height: 600px;
width: 480px;
border-radius: 16px;
background: #ffffff;
box-sizing: border-box;
border: 1px solid #f2f2f6;
box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.12);
padding: 32px 24px 24px;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.lock-icon {
width: 120px;
height: 120px;
margin: 0 auto;
object-fit: contain;
display: block;
}
h1 {
margin: 8px 0 0;
font-family:
"PingFang SC",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
sans-serif;
font-size: 18px;
font-weight: 600;
line-height: 44px;
text-align: center;
letter-spacing: normal;
color: #181c1f;
}
p {
margin: 0;
font-family:
"PingFang SC",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
sans-serif;
font-size: 14px;
font-weight: normal;
line-height: 21px;
text-align: center;
letter-spacing: normal;
color: rgba(24, 28, 31, 0.6);
}
</style>
</head>
<body>
<div class="card">
<img
class="lock-icon"
src="https://img.alicdn.com/imgextra/i4/O1CN01fS3xxz1vbzZSGjbe0_!!6000000006192-2-tps-480-480.png"
alt="lock icon"
/>
<h1>企业安全认证未通过</h1>
<p>__ENTERPRISE_DENIED_MSG__</p>
</div>
</body>
</html>`
// defaultEnterpriseDeniedMsg is shown when the server returns no errorMsg.
const defaultEnterpriseDeniedMsg = "本次请求未通过企业安全认证"
// renderEnterpriseDeniedHTML injects the server-provided denial message (falling
// back to the default text) into the enterprise-denied page. The message is
// HTML-escaped before insertion.
func renderEnterpriseDeniedHTML(serverMsg string) string {
msg := strings.TrimSpace(serverMsg)
if msg == "" {
msg = defaultEnterpriseDeniedMsg
}
return strings.ReplaceAll(enterpriseDeniedHTML, "__ENTERPRISE_DENIED_MSG__", html.EscapeString(msg)+" 此页面可以关闭。")
}
// CLIAuthStatus represents the response from /cli/cliAuthEnabled API. // CLIAuthStatus represents the response from /cli/cliAuthEnabled API.
type CLIAuthStatus struct { type CLIAuthStatus struct {
Success bool `json:"success"` Success bool `json:"success"`
@@ -1179,6 +1287,9 @@ func classifyDenialReason(status *CLIAuthStatus, currentChannel string) string {
if status.ErrorCode == "CHANNEL_REQUIRED" { if status.ErrorCode == "CHANNEL_REQUIRED" {
return "channel_required" return "channel_required"
} }
if status.ErrorCode == "ENTERPRISE_NOT_AUTHORIZED" {
return "enterprise_not_authorized"
}
if status.ErrorCode == "NO_AUTH" { if status.ErrorCode == "NO_AUTH" {
return "no_auth" return "no_auth"
} }
@@ -1268,6 +1379,7 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s
if ch := os.Getenv("DWS_CHANNEL"); ch != "" { if ch := os.Getenv("DWS_CHANNEL"); ch != "" {
req.Header.Set("x-dws-channel", ch) req.Header.Set("x-dws-channel", ch)
} }
applyEditionEnterpriseCredentialHeaders(req)
client := p.httpClient client := p.httpClient
if client == nil { if client == nil {
@@ -1319,6 +1431,7 @@ func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespo
return nil, fmt.Errorf("creating request: %w", err) return nil, fmt.Errorf("creating request: %w", err)
} }
req.Header.Set("x-user-access-token", accessToken) req.Header.Set("x-user-access-token", accessToken)
applyEditionEnterpriseCredentialHeaders(req)
resp, err := oauthHTTPClient.Do(req) resp, err := oauthHTTPClient.Do(req)
if err != nil { if err != nil {
@@ -1366,6 +1479,7 @@ func doSendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (
return nil, fmt.Errorf("creating request: %w", err) return nil, fmt.Errorf("creating request: %w", err)
} }
req.Header.Set("x-user-access-token", accessToken) req.Header.Set("x-user-access-token", accessToken)
applyEditionEnterpriseCredentialHeaders(req)
resp, err := oauthHTTPClient.Do(req) resp, err := oauthHTTPClient.Do(req)
if err != nil { if err != nil {
+17 -1
View File
@@ -23,6 +23,7 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"strings"
"sync" "sync"
"time" "time"
@@ -150,6 +151,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
err error err error
cliAuthDisabled bool cliAuthDisabled bool
denialReason string denialReason string
errorMsg string // server-provided errorMsg from /cli/cliAuthEnabled
} }
resultCh := make(chan callbackResult, 1) resultCh := make(chan callbackResult, 1)
errCh := make(chan error, 1) errCh := make(chan error, 1)
@@ -270,6 +272,13 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
} }
cliAuthEnabled := denialReason == "" cliAuthEnabled := denialReason == ""
// Server-provided errorMsg (nil-safe), surfaced both on the page and to
// the terminal so portal can update copy without releasing the CLI.
serverMsg := ""
if authStatus != nil {
serverMsg = authStatus.ErrorMsg
}
// Update CLI auth disabled state // Update CLI auth disabled state
callbackTokenMu.Lock() callbackTokenMu.Lock()
callbackAuthDisabled = !cliAuthEnabled callbackAuthDisabled = !cliAuthEnabled
@@ -284,6 +293,8 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_, _ = fmt.Fprint(w, accessDeniedHTML) _, _ = fmt.Fprint(w, accessDeniedHTML)
case denialReason == "channel_not_allowed" || denialReason == "channel_required": case denialReason == "channel_not_allowed" || denialReason == "channel_required":
_, _ = fmt.Fprint(w, channelDeniedHTML) _, _ = fmt.Fprint(w, channelDeniedHTML)
case denialReason == "enterprise_not_authorized":
_, _ = fmt.Fprint(w, renderEnterpriseDeniedHTML(serverMsg))
default: default:
_, _ = fmt.Fprint(w, notEnabledHTML) _, _ = fmt.Fprint(w, notEnabledHTML)
} }
@@ -293,7 +304,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
} }
// Notify main goroutine with full result // Notify main goroutine with full result
select { select {
case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled, denialReason: denialReason}: case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled, denialReason: denialReason, errorMsg: serverMsg}:
default: default:
} }
}) })
@@ -444,6 +455,11 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
return nil, errors.New(i18n.T("您不在该组织的 CLI 授权人员范围内,请联系组织管理员将您加入授权名单")) return nil, errors.New(i18n.T("您不在该组织的 CLI 授权人员范围内,请联系组织管理员将您加入授权名单"))
case "channel_not_allowed", "channel_required": case "channel_not_allowed", "channel_required":
return nil, errors.New(i18n.T("当前渠道未获得该组织授权,或组织已开启渠道管控,请联系组织管理员开通渠道访问权限,或升级到最新版本的 CLI")) return nil, errors.New(i18n.T("当前渠道未获得该组织授权,或组织已开启渠道管控,请联系组织管理员开通渠道访问权限,或升级到最新版本的 CLI"))
case "enterprise_not_authorized":
if msg := strings.TrimSpace(result.errorMsg); msg != "" {
return nil, errors.New(msg)
}
return nil, errors.New(i18n.T("本次请求未通过企业安全认证"))
} }
_, _ = fmt.Fprintln(p.output(), "") _, _ = fmt.Fprintln(p.output(), "")
+81 -4
View File
@@ -17,6 +17,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"regexp"
"strings" "strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
@@ -121,7 +122,7 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
newChatBotSearchCommand(runner), newChatBotSearchCommand(runner),
) )
root.AddCommand(message, group, bot, newChatFileGroup(runner)) root.AddCommand(message, group, bot, newChatFileGroup(runner), newChatMediaGroup())
return root return root
} }
@@ -324,7 +325,7 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("text", "", "消息内容,支持 Markdown (也可作位置参数)") cmd.Flags().String("text", "", "消息内容,支持 Markdown (也可作位置参数)")
cmd.Flags().String("title", "", "消息标题 (可选,未指定时从内容截取)") cmd.Flags().String("title", "", "消息标题 (可选,未指定时从内容截取)")
cmd.Flags().Bool("at-all", false, "@所有人 (仅 --group 群聊生效)") cmd.Flags().Bool("at-all", false, "@所有人 (仅 --group 群聊生效)")
cmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员 openDingTalkId 列表,逗号分隔 (仅 --group 群聊生效)") cmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员 openDingtalkId 列表,逗号分隔 (仅 --group 群聊生效);@ 群内机器人时,务必使用 `dws chat group bots --group <openConversationId>` 返回的 openDingtalkId(群级别 ID,与全局搜索结果不同)")
cmd.Flags().String("uuid", "", "幂等 UUID (可选,24h 内相同 uuid 不重复发送)") cmd.Flags().String("uuid", "", "幂等 UUID (可选,24h 内相同 uuid 不重复发送)")
cmd.Flags().String("msg-type", "", "富媒体类型: image / file (纯文本/Markdown 留空)") cmd.Flags().String("msg-type", "", "富媒体类型: image / file (纯文本/Markdown 留空)")
cmd.Flags().String("media-id", "", "图片 mediaId (msg-type=image 时必填)") cmd.Flags().String("media-id", "", "图片 mediaId (msg-type=image 时必填)")
@@ -530,7 +531,7 @@ func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs, Args: cobra.NoArgs,
DisableAutoGenTag: true, DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
params, tool, err := buildChatMessageSendByBotInvocation(cmd) params, tool, err := buildChatMessageSendByBotInvocation(cmd, runner)
if err != nil { if err != nil {
return err return err
} }
@@ -556,6 +557,9 @@ func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("text", "", "消息内容 Markdown (必填)") cmd.Flags().String("text", "", "消息内容 Markdown (必填)")
cmd.Flags().String("title", "", "消息标题 (必填)") cmd.Flags().String("title", "", "消息标题 (必填)")
cmd.Flags().String("users", "", "接收者 userId 列表,逗号分隔,最多 20 个 (单聊必填)") cmd.Flags().String("users", "", "接收者 userId 列表,逗号分隔,最多 20 个 (单聊必填)")
cmd.Flags().Bool("at-all", false, "@所有人 (仅 --group 群聊生效)")
cmd.Flags().String("at-user-ids", "", "@指定成员 userId 列表,逗号分隔 (仅 --group 生效);文中的 <@userId> 会被替换为 markdown @userId 语法")
cmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员 openDingtalkId 列表,逗号分隔 (仅 --group 生效);@ 群内机器人时,务必使用 `dws chat group bots --group <openConversationId>` 返回的 openDingtalkId(群级别 ID,与全局搜索结果不同)")
return cmd return cmd
} }
@@ -642,7 +646,7 @@ func newChatGroupCreateCommand(runner executor.Runner) *cobra.Command {
return cmd return cmd
} }
func buildChatMessageSendByBotInvocation(cmd *cobra.Command) (map[string]any, string, error) { func buildChatMessageSendByBotInvocation(cmd *cobra.Command, runner executor.Runner) (map[string]any, string, error) {
guard := cli.NewStdinGuard() guard := cli.NewStdinGuard()
group, err := cmd.Flags().GetString("group") group, err := cmd.Flags().GetString("group")
@@ -690,6 +694,33 @@ func buildChatMessageSendByBotInvocation(cmd *cobra.Command) (map[string]any, st
} }
if strings.TrimSpace(group) != "" { if strings.TrimSpace(group) != "" {
params["openConversationId"] = group params["openConversationId"] = group
atAll, _ := cmd.Flags().GetBool("at-all")
atUserIDs, _ := cmd.Flags().GetString("at-user-ids")
atOpenIDs, _ := cmd.Flags().GetString("at-open-dingtalk-ids")
if atAll {
params["atAll"] = true
}
userIDList := splitCSVStrings(atUserIDs)
openIDList := splitCSVStrings(atOpenIDs)
// The robot group message API honors two @ dimensions directly:
// atUserIds (staffId) and atOpendingtalkIds (openDingTalkId — note the
// server's lowercase spelling, verified live: the camelCase
// atOpenDingTalkIds is silently ignored). openDingTalkId is the ONLY id
// a bot has, so forwarding it verbatim is what makes bot→bot @ work —
// no userId reverse-lookup needed (and a bot is not in the member
// roster, so a lookup could never resolve it anyway). Each dimension
// also needs the matching `@id` token in the body to render the chip
// (renderAtMentions below rewrites `<@id>` → `@id` and prepends any
// missing mention).
if len(userIDList) > 0 {
params["atUserIds"] = stringSliceToAny(userIDList)
}
if len(openIDList) > 0 {
params["atOpendingtalkIds"] = stringSliceToAny(openIDList)
}
if allIDs := append(append([]string{}, userIDList...), openIDList...); len(allIDs) > 0 {
params["markdown"] = renderAtMentions(params["markdown"].(string), allIDs)
}
return params, "send_robot_group_message", nil return params, "send_robot_group_message", nil
} }
@@ -723,6 +754,52 @@ func splitCSVStrings(raw string) []string {
return values return values
} }
// atMentionPlaceholder captures both <@userId> (angle-bracket) and standalone
// @userId placeholders so a caller can write `<@u123>` or `@u123` in the body
// and have it rendered as a DingTalk mention chip.
var atMentionPlaceholder = regexp.MustCompile(`<@([^>\s]+)>`)
// renderAtMentions rewrites mention placeholders in the markdown body so the
// DingTalk client renders a highlight chip. In DingTalk markdown, a mention is
// the literal token `@userId` followed by whitespace; the recipient side then
// looks up the userId in the message's atUserIds array to render it as a chip.
// We (a) rewrite `<@userId>` → `@userId `, and (b) if none of the mentioned
// userIds appear in the body at all, prepend them so the chip still shows up.
func renderAtMentions(body string, userIDs []string) string {
if len(userIDs) == 0 {
return body
}
rewritten := atMentionPlaceholder.ReplaceAllString(body, "@$1 ")
referenced := map[string]bool{}
matches := atMentionPlaceholder.FindAllStringSubmatch(body, -1)
for _, m := range matches {
if len(m) > 1 {
referenced[m[1]] = true
}
}
// Also detect bare @userId occurrences already present in the text.
for _, uid := range userIDs {
if strings.Contains(rewritten, "@"+uid) {
referenced[uid] = true
}
}
var missing []string
for _, uid := range userIDs {
if !referenced[uid] {
missing = append(missing, "@"+uid)
}
}
if len(missing) > 0 {
prefix := strings.Join(missing, " ")
if strings.TrimSpace(rewritten) == "" {
rewritten = prefix
} else {
rewritten = prefix + " " + rewritten
}
}
return rewritten
}
func stringSliceToAny(values []string) []any { func stringSliceToAny(values []string) []any {
out := make([]any, 0, len(values)) out := make([]any, 0, len(values))
for _, value := range values { for _, value := range values {
+191
View File
@@ -0,0 +1,191 @@
// 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 helpers
import (
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
func newChatMediaGroup() *cobra.Command {
media := &cobra.Command{
Use: "media",
Short: "媒体文件管理",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
}
media.AddCommand(newChatMediaUploadCommand())
return media
}
func newChatMediaUploadCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "upload",
Short: "上传图片获取 mediaId(用于 chat message send --msg-type image)",
Example: " dws chat media upload --file ./screenshot.png\n" +
" dws chat media upload --file ./photo.jpg --type image",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
filePath, _ := cmd.Flags().GetString("file")
filePath = strings.TrimSpace(filePath)
if filePath == "" {
return apperrors.NewValidation("--file is required")
}
fi, err := os.Stat(filePath)
if err != nil {
return apperrors.NewValidation("cannot read file: " + err.Error())
}
if fi.IsDir() {
return apperrors.NewValidation(filePath + " is a directory")
}
mediaType, _ := cmd.Flags().GetString("type")
mediaType = strings.TrimSpace(strings.ToLower(mediaType))
if mediaType == "" {
mediaType = "image"
}
ctx, cancel := context.WithTimeout(cmd.Context(), 2*time.Minute)
defer cancel()
token, err := mediaResolveAppToken(ctx)
if err != nil {
return err
}
mediaID, err := mediaUploadFile(ctx, token, filePath, mediaType)
if err != nil {
return err
}
return writeCommandPayload(cmd, map[string]any{
"success": true,
"mediaId": mediaID,
})
},
}
cmd.Flags().String("file", "", "本地文件路径 (必填)")
cmd.Flags().String("type", "image", "媒体类型: image/voice/video/file")
return cmd
}
func mediaResolveAppToken(ctx context.Context) (string, error) {
appKey := os.Getenv("DWS_CLIENT_ID")
appSecret := os.Getenv("DWS_CLIENT_SECRET")
if appKey == "" || appSecret == "" {
return "", apperrors.NewAuth(
"缺少应用凭证。chat media upload 需要 DWS_CLIENT_ID / DWS_CLIENT_SECRET 环境变量。\n" +
"请使用 dws auth login --client-id <APP_KEY> --client-secret <APP_SECRET> 登录。")
}
// media/upload is only served by the legacy oapi endpoint and requires the
// legacy access_token from oapi.dingtalk.com/gettoken (NOT the v1.0
// accessToken). Using the v1.0 token against media/upload returns
// HTTP 404 InvalidAction.NotFound.
url := "https://oapi.dingtalk.com/gettoken?appkey=" + appKey + "&appsecret=" + appSecret
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return "", apperrors.NewAuth("获取访问令牌失败: " + err.Error())
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode >= 400 {
return "", apperrors.NewAuth(fmt.Sprintf("获取访问令牌 HTTP %d: %s", resp.StatusCode, string(raw)))
}
var parsed struct {
AccessToken string `json:"access_token"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return "", apperrors.NewAuth("gettoken 响应解析失败: " + string(raw))
}
if parsed.ErrCode != 0 || parsed.AccessToken == "" {
return "", apperrors.NewAuth(fmt.Sprintf("gettoken errcode=%d errmsg=%s", parsed.ErrCode, parsed.ErrMsg))
}
return parsed.AccessToken, nil
}
func mediaUploadFile(ctx context.Context, token, filePath, mediaType string) (string, error) {
pr, pw := io.Pipe()
writer := multipart.NewWriter(pw)
go func() {
defer pw.Close()
defer writer.Close()
part, err := writer.CreateFormFile("media", filepath.Base(filePath))
if err != nil {
pw.CloseWithError(err)
return
}
f, err := os.Open(filePath)
if err != nil {
pw.CloseWithError(err)
return
}
defer f.Close()
if _, err := io.Copy(part, f); err != nil {
pw.CloseWithError(err)
return
}
}()
// media/upload takes access_token + type as query params, media file as
// multipart form field "media"; returns { errcode, errmsg, media_id }.
url := "https://oapi.dingtalk.com/media/upload?access_token=" + token + "&type=" + mediaType
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, pr)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := (&http.Client{Timeout: 2 * time.Minute}).Do(req)
if err != nil {
return "", apperrors.NewAPI("media upload failed: " + err.Error())
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode >= 400 {
return "", apperrors.NewAPI(fmt.Sprintf("media upload HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))))
}
var parsed struct {
MediaID string `json:"media_id"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return "", apperrors.NewAPI("media upload 响应解析失败: " + string(body))
}
if parsed.ErrCode != 0 || strings.TrimSpace(parsed.MediaID) == "" {
return "", apperrors.NewAPI(fmt.Sprintf("media upload errcode=%d errmsg=%s body=%s", parsed.ErrCode, parsed.ErrMsg, string(body)))
}
return strings.TrimSpace(parsed.MediaID), nil
}
+124
View File
@@ -427,6 +427,82 @@ func TestChatMessageSendByBotOmitsClawType(t *testing.T) {
} }
} }
// send-by-bot --at-user-ids: userIds go into atUserIds AND the body's
// `<@userId>` placeholder is rewritten to the `@userId ` chip form. Regression
// guard for 勤泽 2026-07-04 report where the body still shipped raw `<@id>`.
func TestChatMessageSendByBotAtUserIDsRendersChip(t *testing.T) {
runner := &captureRunner{}
cmd := newChatMessageSendByBotCommand(runner)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{
"--group", "cid-xyz",
"--robot-code", "robot-001",
"--title", "t",
"--text", "<@u1> ping",
"--at-user-ids", "u1",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
got, ok := runner.last.Params["markdown"].(string)
if !ok {
t.Fatalf("markdown missing or not string; got %#v", runner.last.Params["markdown"])
}
if !strings.Contains(got, "@u1 ") {
t.Fatalf("markdown should contain rewritten @u1 chip, got %q", got)
}
if strings.Contains(got, "<@u1>") {
t.Fatalf("markdown should NOT contain raw placeholder <@u1>, got %q", got)
}
users, ok := runner.last.Params["atUserIds"].([]any)
if !ok || len(users) != 1 || users[0] != "u1" {
t.Fatalf("atUserIds = %#v, want [u1]", runner.last.Params["atUserIds"])
}
}
// send-by-bot --at-open-dingtalk-ids: the openDingTalkId is forwarded verbatim
// as atOpendingtalkIds (the server's lowercase spelling — the camelCase
// atOpenDingTalkIds is silently ignored), and the `<@openId>` placeholder is
// rewritten to `@openId` in the body so the chip renders. This is what makes
// @-ing a bot (whose only id is an openDingTalkId) actually deliver.
func TestChatMessageSendByBotAtOpenIDsRendersChip(t *testing.T) {
runner := &captureRunner{}
cmd := newChatMessageSendByBotCommand(runner)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{
"--group", "cid-xyz",
"--robot-code", "robot-001",
"--title", "t",
"--text", "<@op1> ping",
"--at-open-dingtalk-ids", "op1",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
got, ok := runner.last.Params["markdown"].(string)
if !ok {
t.Fatalf("markdown missing or not string; got %#v", runner.last.Params["markdown"])
}
if !strings.Contains(got, "@op1 ") {
t.Fatalf("markdown should contain rewritten @op1 chip, got %q", got)
}
if strings.Contains(got, "<@op1>") {
t.Fatalf("markdown should NOT contain raw placeholder <@op1>, got %q", got)
}
opens, ok := runner.last.Params["atOpendingtalkIds"].([]any)
if !ok || len(opens) != 1 || opens[0] != "op1" {
t.Fatalf("atOpendingtalkIds = %#v, want [op1]", runner.last.Params["atOpendingtalkIds"])
}
// The camelCase field must NOT be set (the server ignores it).
if _, exists := runner.last.Params["atOpenDingTalkIds"]; exists {
t.Fatalf("must not send camelCase atOpenDingTalkIds; params=%#v", runner.last.Params)
}
}
func equalAny(a, b any) bool { func equalAny(a, b any) bool {
switch av := a.(type) { switch av := a.(type) {
case []any: case []any:
@@ -504,3 +580,51 @@ func TestChatMessageSendByBotRoutesToBotProduct(t *testing.T) {
}) })
} }
} }
func TestRenderAtMentions(t *testing.T) {
cases := []struct {
name string
body string
userIDs []string
want string
}{
{
name: "empty userIDs is no-op",
body: "hello",
userIDs: nil,
want: "hello",
},
{
name: "angle placeholder rewritten",
body: "<@u001> 请查收",
userIDs: []string{"u001"},
want: "@u001 请查收",
},
{
name: "missing mention auto-prepended",
body: "请查收",
userIDs: []string{"u001", "u002"},
want: "@u001 @u002 请查收",
},
{
name: "bare @userId already present, no prefix",
body: "hi @u001 please",
userIDs: []string{"u001"},
want: "hi @u001 please",
},
{
name: "mixed: one placeholder + one missing",
body: "<@u001> ping",
userIDs: []string{"u001", "u002"},
want: "@u002 @u001 ping",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := renderAtMentions(tc.body, tc.userIDs)
if got != tc.want {
t.Fatalf("renderAtMentions(%q, %v) = %q, want %q", tc.body, tc.userIDs, got, tc.want)
}
})
}
}
+11 -5
View File
@@ -168,15 +168,21 @@ func (c *aiCardClient) callRaw(ctx context.Context, method, path string, payload
if err != nil { if err != nil {
return "", err return "", err
} }
body, err := json.Marshal(payload) var bodyReader io.Reader
if payload != nil {
body, merr := json.Marshal(payload)
if merr != nil {
return "", merr
}
bodyReader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, dingtalkCardAPIBase+path, bodyReader)
if err != nil { if err != nil {
return "", err return "", err
} }
req, err := http.NewRequestWithContext(ctx, method, dingtalkCardAPIBase+path, bytes.NewReader(body)) if payload != nil {
if err != nil { req.Header.Set("Content-Type", "application/json")
return "", err
} }
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-acs-dingtalk-access-token", token) req.Header.Set("x-acs-dingtalk-access-token", token)
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
+1 -1
View File
@@ -103,7 +103,7 @@ func (f *codexAppServerForwarder) forwardStream(ctx context.Context, convID, tex
} }
func (f *codexAppServerForwarder) forwardAppServer(ctx context.Context, convID, text string, onDelta func(string)) (string, error) { func (f *codexAppServerForwarder) forwardAppServer(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
ctx, cancel := context.WithTimeout(ctx, f.timeout) ctx, cancel := applyTimeout(ctx, f.timeout)
defer cancel() defer cancel()
var state *codexThreadState var state *codexThreadState
+511 -48
View File
@@ -26,8 +26,11 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -58,11 +61,21 @@ const (
// supervisor pid plus enough context for `status` to report without re-deriving // supervisor pid plus enough context for `status` to report without re-deriving
// it (start time for uptime, log path, the dir key it was filed under). // it (start time for uptime, log path, the dir key it was filed under).
type daemonState struct { type daemonState struct {
Pid int `json:"pid"` Pid int `json:"pid"`
StartUnix int64 `json:"startUnix"` StartUnix int64 `json:"startUnix"`
LogPath string `json:"logPath"` LogPath string `json:"logPath"`
DirKey string `json:"dirKey"` DirKey string `json:"dirKey"`
ClientID string `json:"clientId,omitempty"` ClientID string `json:"clientId,omitempty"`
UnifiedAppID string `json:"unifiedAppId,omitempty"`
Channel string `json:"channel,omitempty"`
NotifyStaffID string `json:"notifyStaffId,omitempty"`
// Profile records the --profile selector the connector was started with,
// so `restart` re-fetches credentials against the same org instead of the
// default profile (which may not know the unifiedAppId at all).
Profile string `json:"profile,omitempty"`
// AlwaysOn controls whether the supervisor auto-restarts the worker on
// crash. Without it the supervisor exits after the first worker exit.
AlwaysOn bool `json:"alwaysOn,omitempty"`
} }
// connectDaemonDirOverride lets tests redirect the per-client daemon directory // connectDaemonDirOverride lets tests redirect the per-client daemon directory
@@ -97,38 +110,42 @@ func connectDaemonDir(dirKey string) (string, error) {
return dir, nil return dir, nil
} }
func daemonPidPath(dir string) string { return filepath.Join(dir, "daemon.pid") } func daemonPidPath(dir string) string { return filepath.Join(dir, "daemon.pid") }
func daemonLogPath(dir string) string { return filepath.Join(dir, "daemon.log") } func daemonStatePath(dir string) string { return filepath.Join(dir, "daemon-state.json") }
func daemonLogPath(dir string) string { return filepath.Join(dir, "daemon.log") }
// writeDaemonState atomically persists the daemon pid file (write temp + rename) // writeDaemonState atomically persists the daemon state to daemon-state.json
// so a reader never sees a half-written file. // (persistent, survives supervisor exit) so restart/list can recover config.
func writeDaemonState(dir string, st daemonState) error { func writeDaemonState(dir string, st daemonState) error {
data, err := json.MarshalIndent(st, "", " ") data, err := json.MarshalIndent(st, "", " ")
if err != nil { if err != nil {
return err return err
} }
tmp := daemonPidPath(dir) + ".tmp" tmp := daemonStatePath(dir) + ".tmp"
if err := os.WriteFile(tmp, data, config.FilePerm); err != nil { if err := os.WriteFile(tmp, data, config.FilePerm); err != nil {
return err return err
} }
return os.Rename(tmp, daemonPidPath(dir)) return os.Rename(tmp, daemonStatePath(dir))
} }
// readDaemonState loads the daemon pid file. A missing file yields (nil, nil) so // readDaemonState loads the daemon state. Reads daemon-state.json (persistent)
// callers can treat "not running" distinctly from a real I/O error. // first, falls back to daemon.pid for backward compat with old connectors.
func readDaemonState(dir string) (*daemonState, error) { func readDaemonState(dir string) (*daemonState, error) {
data, err := os.ReadFile(daemonPidPath(dir)) for _, p := range []string{daemonStatePath(dir), daemonPidPath(dir)} {
if err != nil { data, err := os.ReadFile(p)
if os.IsNotExist(err) { if err != nil {
return nil, nil if os.IsNotExist(err) {
continue
}
return nil, err
} }
return nil, err var st daemonState
if err := json.Unmarshal(data, &st); err != nil {
return nil, fmt.Errorf("daemon state file %s is corrupt: %w", p, err)
}
return &st, nil
} }
var st daemonState return nil, nil
if err := json.Unmarshal(data, &st); err != nil {
return nil, fmt.Errorf("daemon pid file %s is corrupt: %w", daemonPidPath(dir), err)
}
return &st, nil
} }
// backoffDelay computes the restart delay for the Nth consecutive worker // backoffDelay computes the restart delay for the Nth consecutive worker
@@ -190,7 +207,7 @@ func buildWorkerArgs(args []string) []string {
// startDaemon implements `connect --daemon`: it re-execs dws in supervisor mode // startDaemon implements `connect --daemon`: it re-execs dws in supervisor mode
// detached from the terminal, writes nothing itself to the worker log (the // detached from the terminal, writes nothing itself to the worker log (the
// supervisor does), prints the pid + log path, and returns so the parent exits. // supervisor does), prints the pid + log path, and returns so the parent exits.
func startDaemon(cmd *cobra.Command, dirKey, clientID string) error { func startDaemon(cmd *cobra.Command, dirKey, clientID, unifiedAppID, channel, notifyStaffID, profile string, alwaysOn bool) error {
if !daemonDetachSupported { if !daemonDetachSupported {
return apperrors.NewValidation("--daemon is not supported on this OS; run the foreground connector under a service manager instead") return apperrors.NewValidation("--daemon is not supported on this OS; run the foreground connector under a service manager instead")
} }
@@ -228,7 +245,14 @@ func startDaemon(cmd *cobra.Command, dirKey, clientID string) error {
child.Env = append(os.Environ(), child.Env = append(os.Environ(),
"DWS_CONNECT_DAEMON_DIRKEY="+dirKey, "DWS_CONNECT_DAEMON_DIRKEY="+dirKey,
"DWS_CONNECT_DAEMON_CLIENTID="+clientID, "DWS_CONNECT_DAEMON_CLIENTID="+clientID,
"DWS_CONNECT_DAEMON_UNIFIEDAPPID="+unifiedAppID,
"DWS_CONNECT_DAEMON_CHANNEL="+channel,
"DWS_CONNECT_DAEMON_NOTIFY_STAFF_ID="+notifyStaffID,
"DWS_CONNECT_DAEMON_PROFILE="+profile,
) )
if alwaysOn {
child.Env = append(child.Env, "DWS_CONNECT_DAEMON_ALWAYSON=true")
}
if connectDaemonDirOverride != "" { if connectDaemonDirOverride != "" {
child.Env = append(child.Env, "DWS_CONNECT_DAEMON_DIR="+connectDaemonDirOverride) child.Env = append(child.Env, "DWS_CONNECT_DAEMON_DIR="+connectDaemonDirOverride)
} }
@@ -248,8 +272,8 @@ func startDaemon(cmd *cobra.Command, dirKey, clientID string) error {
func writeConnectDaemonStarted(w io.Writer, pid int, logPath, clientID, dirKey string) { func writeConnectDaemonStarted(w io.Writer, pid int, logPath, clientID, dirKey string) {
fmt.Fprintf(w, "connect daemon started (pid %d)\n", pid) fmt.Fprintf(w, "connect daemon started (pid %d)\n", pid)
fmt.Fprintf(w, " logs: %s\n", logPath) fmt.Fprintf(w, " logs: %s\n", logPath)
fmt.Fprintf(w, " status: dws devapp robot connect status%s\n", statusHintArgs(clientID, dirKey)) fmt.Fprintf(w, " status: dws dev connect status%s\n", statusHintArgs(clientID, dirKey))
fmt.Fprintf(w, " stop: dws devapp robot connect stop%s\n", statusHintArgs(clientID, dirKey)) fmt.Fprintf(w, " stop: dws dev connect stop%s\n", statusHintArgs(clientID, dirKey))
fmt.Fprint(w, connectLocalDebugNotice()) fmt.Fprint(w, connectLocalDebugNotice())
} }
@@ -291,16 +315,26 @@ func runSupervisor(cmd *cobra.Command) error {
connectDaemonDirOverride = v connectDaemonDirOverride = v
} }
clientID := strings.TrimSpace(os.Getenv("DWS_CONNECT_DAEMON_CLIENTID")) clientID := strings.TrimSpace(os.Getenv("DWS_CONNECT_DAEMON_CLIENTID"))
unifiedAppID := strings.TrimSpace(os.Getenv("DWS_CONNECT_DAEMON_UNIFIEDAPPID"))
channel := strings.TrimSpace(os.Getenv("DWS_CONNECT_DAEMON_CHANNEL"))
notifyStaffID := strings.TrimSpace(os.Getenv("DWS_CONNECT_DAEMON_NOTIFY_STAFF_ID"))
profile := strings.TrimSpace(os.Getenv("DWS_CONNECT_DAEMON_PROFILE"))
alwaysOn := strings.TrimSpace(os.Getenv("DWS_CONNECT_DAEMON_ALWAYSON")) == "true"
dir, err := connectDaemonDir(dirKey) dir, err := connectDaemonDir(dirKey)
if err != nil { if err != nil {
return apperrors.NewInternal("create daemon dir: " + err.Error()) return apperrors.NewInternal("create daemon dir: " + err.Error())
} }
st := daemonState{ st := daemonState{
Pid: os.Getpid(), Pid: os.Getpid(),
StartUnix: time.Now().Unix(), StartUnix: time.Now().Unix(),
LogPath: daemonLogPath(dir), LogPath: daemonLogPath(dir),
DirKey: dirKey, DirKey: dirKey,
ClientID: clientID, ClientID: clientID,
UnifiedAppID: unifiedAppID,
Channel: channel,
NotifyStaffID: notifyStaffID,
Profile: profile,
AlwaysOn: alwaysOn,
} }
if err := writeDaemonState(dir, st); err != nil { if err := writeDaemonState(dir, st); err != nil {
return apperrors.NewInternal("write daemon pid file: " + err.Error()) return apperrors.NewInternal("write daemon pid file: " + err.Error())
@@ -331,16 +365,19 @@ func runSupervisor(cmd *cobra.Command) error {
} }
failures := 0 failures := 0
daemonNotifyStateChange(notifyStaffID, channel, clientID, "started", "")
for { for {
if delay := backoffDelay(failures, daemonBackoffBase, daemonBackoffCap); delay > 0 { if delay := backoffDelay(failures, daemonBackoffBase, daemonBackoffCap); delay > 0 {
fmt.Fprintf(out, "[daemon] restarting worker in %s (consecutive failures: %d)\n", delay, failures) fmt.Fprintf(out, "[daemon] restarting worker in %s (consecutive failures: %d)\n", delay, failures)
select { select {
case <-ctx.Done(): case <-ctx.Done():
daemonNotifyStateChange(notifyStaffID, channel, clientID, "stopped", "")
return nil return nil
case <-time.After(delay): case <-time.After(delay):
} }
} }
if ctx.Err() != nil { if ctx.Err() != nil {
daemonNotifyStateChange(notifyStaffID, channel, clientID, "stopped", "")
return nil return nil
} }
@@ -353,6 +390,7 @@ func runSupervisor(cmd *cobra.Command) error {
fmt.Fprintf(out, "[daemon] failed to start worker: %v\n", err) fmt.Fprintf(out, "[daemon] failed to start worker: %v\n", err)
failures++ failures++
if failures >= daemonMaxFastFailures { if failures >= daemonMaxFastFailures {
daemonNotifyStateChange(notifyStaffID, channel, clientID, "gave_up", fmt.Sprintf("worker 启动失败 %d 次", failures))
return apperrors.NewInternal("daemon worker failed to start too many times; giving up") return apperrors.NewInternal("daemon worker failed to start too many times; giving up")
} }
continue continue
@@ -365,6 +403,13 @@ func runSupervisor(cmd *cobra.Command) error {
if ctx.Err() != nil { if ctx.Err() != nil {
// We were asked to stop; the worker has been (or is being) signalled. // We were asked to stop; the worker has been (or is being) signalled.
fmt.Fprintln(out, "[daemon] stop requested, worker shut down; exiting supervisor") fmt.Fprintln(out, "[daemon] stop requested, worker shut down; exiting supervisor")
daemonNotifyStateChange(notifyStaffID, channel, clientID, "stopped", "")
return nil
}
if !alwaysOn {
fmt.Fprintln(out, "[daemon] worker exited; not restarting (--alwayson not set)")
daemonNotifyStateChange(notifyStaffID, channel, clientID, "stopped", "worker 退出,未启用 --alwayson")
return nil return nil
} }
@@ -375,8 +420,10 @@ func runSupervisor(cmd *cobra.Command) error {
} }
fmt.Fprintf(out, "[daemon] worker exited after %s (err=%v); consecutive failures: %d\n", ran.Round(time.Second), waitErr, failures) fmt.Fprintf(out, "[daemon] worker exited after %s (err=%v); consecutive failures: %d\n", ran.Round(time.Second), waitErr, failures)
if failures >= daemonMaxFastFailures { if failures >= daemonMaxFastFailures {
daemonNotifyStateChange(notifyStaffID, channel, clientID, "gave_up", fmt.Sprintf("连续崩溃 %d 次", failures))
return apperrors.NewInternal(fmt.Sprintf("daemon worker crashed %d times in a row; giving up (check %s)", failures, daemonLogPath(dir))) return apperrors.NewInternal(fmt.Sprintf("daemon worker crashed %d times in a row; giving up (check %s)", failures, daemonLogPath(dir)))
} }
daemonNotifyStateChange(notifyStaffID, channel, clientID, "crashed", fmt.Sprintf("worker 退出 (%s),正在重启", ran.Round(time.Second)))
} }
} }
@@ -401,8 +448,12 @@ func superviseWait(ctx context.Context, worker *exec.Cmd) error {
} }
} }
// daemonStatus reports the state of the connector daemon to w. // daemonStatus reports connector health to w. It combines two independent
func daemonStatus(w io.Writer, dirKey string) error { // signals: the daemon supervisor pid file (is a supervisor alive) and the
// connector heartbeat (is the connection live and receiving — see
// connect_health.go). jsonOut emits the machine-readable health report an
// external supervisor (launchd/systemd/pm2/cron) consumes to decide restarts.
func daemonStatus(w io.Writer, dirKey string, jsonOut bool) error {
dir, err := connectDaemonDir(dirKey) dir, err := connectDaemonDir(dirKey)
if err != nil { if err != nil {
return apperrors.NewInternal("resolve daemon dir: " + err.Error()) return apperrors.NewInternal("resolve daemon dir: " + err.Error())
@@ -411,23 +462,60 @@ func daemonStatus(w io.Writer, dirKey string) error {
if err != nil { if err != nil {
return apperrors.NewInternal(err.Error()) return apperrors.NewInternal(err.Error())
} }
if st == nil { supervised := st != nil && st.Pid > 0 && processAlive(st.Pid)
fmt.Fprintf(w, "connect daemon: not running (no pid file under %s)\n", dir)
hb, err := readConnectHeartbeat(dir)
if err != nil {
return apperrors.NewInternal("read connector heartbeat: " + err.Error())
}
report := deriveConnectHealth(hb, supervised, time.Now())
if jsonOut {
data, merr := json.MarshalIndent(report, "", " ")
if merr != nil {
return apperrors.NewInternal(merr.Error())
}
fmt.Fprintln(w, string(data))
return nil return nil
} }
if st.Pid <= 0 || !processAlive(st.Pid) {
fmt.Fprintf(w, "connect daemon: not running (stale pid file for pid %d at %s)\n", st.Pid, daemonPidPath(dir)) var lines []string
return nil lines = append(lines, fmt.Sprintf("%s %s", tui.Key("state"), colorConnectState(report.State)))
if report.Detail != "" {
lines = append(lines, fmt.Sprintf("%s %s", tui.Key("detail"), report.Detail))
} }
uptime := time.Since(time.Unix(st.StartUnix, 0)).Round(time.Second) if report.Pid > 0 {
fmt.Fprintf(w, "connect daemon: running\n") lines = append(lines, fmt.Sprintf("%s %s", tui.Key("pid"), tui.White(fmt.Sprintf("%d", report.Pid))))
fmt.Fprintf(w, " pid: %d\n", st.Pid)
fmt.Fprintf(w, " uptime: %s\n", uptime)
fmt.Fprintf(w, " logs: %s\n", st.LogPath)
if st.ClientID != "" {
fmt.Fprintf(w, " client: %s\n", st.ClientID)
} }
return nil if report.Channel != "" {
lines = append(lines, fmt.Sprintf("%s %s", tui.Key("channel"), tui.White(report.Channel)))
}
if report.ClientID != "" {
lines = append(lines, fmt.Sprintf("%s %s", tui.Key("client"), tui.White(report.ClientID)))
}
if report.UptimeSec > 0 {
lines = append(lines, fmt.Sprintf("%s %s", tui.Key("uptime"), tui.White((time.Duration(report.UptimeSec)*time.Second).Round(time.Second).String())))
}
lines = append(lines, fmt.Sprintf("%s %s", tui.Key("super"), supervisedLabel(supervised)))
if hb != nil {
if report.LastPushAgoSec > 0 {
lines = append(lines, fmt.Sprintf("%s %s ago", tui.Key("recv"), tui.White((time.Duration(report.LastPushAgoSec)*time.Second).Round(time.Second).String())))
} else {
lines = append(lines, fmt.Sprintf("%s %s", tui.Key("recv"), tui.Dim("(none since start)")))
}
if report.LastError != "" {
lines = append(lines, fmt.Sprintf("%s %s", tui.Key("error"), tui.Danger(report.LastError)))
}
lines = append(lines, fmt.Sprintf("%s %s", tui.Key("logs"), tui.Dim(daemonLogPath(dir))))
}
return tui.Panel(w, tui.Bold("connect status"), lines)
}
func supervisedLabel(supervised bool) string {
if supervised {
return "running (--daemon)"
}
return "none (foreground or external)"
} }
// daemonStop gracefully stops the connector daemon: SIGTERM the supervisor (it // daemonStop gracefully stops the connector daemon: SIGTERM the supervisor (it
@@ -448,6 +536,28 @@ func daemonStop(w io.Writer, dirKey string) error {
} }
if !processAlive(st.Pid) { if !processAlive(st.Pid) {
_ = os.Remove(daemonPidPath(dir)) _ = os.Remove(daemonPidPath(dir))
// The supervisor is dead, but its worker may still be alive (e.g. the
// supervisor was kill -9'd). Check the heartbeat for the worker pid and
// stop it so we don't leave an orphan.
if hb, _ := readConnectHeartbeat(dir); hb != nil && hb.Pid > 0 && processAlive(hb.Pid) {
fmt.Fprintf(w, "connect daemon: supervisor (pid %d) was dead, stopping orphan worker (pid %d)...\n", st.Pid, hb.Pid)
if proc, perr := os.FindProcess(hb.Pid); perr == nil {
_ = proc.Signal(syscall.SIGTERM)
deadline := time.Now().Add(daemonStopTimeout)
for time.Now().Before(deadline) {
if !processAlive(hb.Pid) {
break
}
time.Sleep(200 * time.Millisecond)
}
if processAlive(hb.Pid) {
_ = proc.Signal(syscall.SIGKILL)
time.Sleep(200 * time.Millisecond)
}
}
fmt.Fprintf(w, "connect daemon: orphan worker stopped (pid %d)\n", hb.Pid)
return nil
}
fmt.Fprintf(w, "connect daemon: was not running (cleaned up stale pid file for pid %d)\n", st.Pid) fmt.Fprintf(w, "connect daemon: was not running (cleaned up stale pid file for pid %d)\n", st.Pid)
return nil return nil
} }
@@ -483,7 +593,7 @@ func daemonStop(w io.Writer, dirKey string) error {
func newDevAppRobotConnectStatusCommand() *cobra.Command { func newDevAppRobotConnectStatusCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "status", Use: "status",
Short: "查看后台连接器守护进程状态(pid、运行时长、日志路径)", Short: "查看连接器健康状态(healthy/degraded/down,pid、收发活动、日志路径;--json 供外部托管消费)",
Args: cobra.NoArgs, Args: cobra.NoArgs,
DisableAutoGenTag: true, DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, _ []string) error {
@@ -491,12 +601,14 @@ func newDevAppRobotConnectStatusCommand() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
return daemonStatus(cmd.OutOrStdout(), dirKey) jsonOut, _ := cmd.Flags().GetBool("json")
return daemonStatus(cmd.OutOrStdout(), dirKey, jsonOut)
}, },
} }
preferLegacyLeaf(cmd) preferLegacyLeaf(cmd)
cmd.Flags().String("robot-client-id", "", "机器人 clientId(定位守护进程)") cmd.Flags().String("robot-client-id", "", "机器人 clientId(定位守护进程)")
cmd.Flags().String("unified-app-id", "", "统一应用 ID(当未用 clientId 起守护进程时定位)") cmd.Flags().String("unified-app-id", "", "统一应用 ID(当未用 clientId 起守护进程时定位)")
cmd.Flags().Bool("json", false, "以 JSON 输出健康报告(供 launchd/systemd/pm2/cron 判断是否重启)")
return cmd return cmd
} }
@@ -523,6 +635,209 @@ func newDevAppRobotConnectStopCommand() *cobra.Command {
return cmd return cmd
} }
// newDevAppRobotConnectRestartCommand implements `dws devapp robot connect
// restart`: stop the running daemon (if any) then re-launch it using the
// persisted unifiedAppId so credentials are freshly fetched from the dev
// platform — no secrets stored on disk.
func newDevAppRobotConnectRestartCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "restart",
Short: "重启连接器守护进程(通过持久化的 unifiedAppId 重新拉取密钥,无需本地存密钥)",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, _ []string) error {
dirKey, err := connectDaemonDirKeyFromFlags(cmd)
if err != nil {
return err
}
dir, err := connectDaemonDir(dirKey)
if err != nil {
return apperrors.NewInternal("resolve daemon dir: " + err.Error())
}
st, err := readDaemonState(dir)
if err != nil {
return apperrors.NewInternal(err.Error())
}
if st == nil {
return apperrors.NewValidation("未找到连接器记录(没有 daemon.pid);请用 `dws dev connect --daemon` 首次启动")
}
unifiedAppID := st.UnifiedAppID
if unifiedAppID == "" {
return apperrors.NewValidation("该连接器未持久化 unifiedAppId(可能是用 --robot-client-id/--robot-client-secret 直接启动的,无法安全重启:clientSecret 不落盘);请停掉后用 `dws dev connect --daemon --unified-app-id <uappid>` 重新启动,之后 restart 就能自动从 credentials get 拉密钥、命令行不出现 secret")
}
// Stop the running daemon first (ignore "not running" — that's fine).
fmt.Fprintln(cmd.OutOrStdout(), "stopping existing daemon...")
if err := daemonStop(cmd.OutOrStdout(), dirKey); err != nil {
fmt.Fprintf(cmd.OutOrStderr(), "warning: stop returned %v (continuing with restart)\n", err)
}
// Re-exec dws dev connect --daemon with the stored flags. An explicit
// --profile on this invocation overrides the persisted one.
exe, err := os.Executable()
if err != nil {
return apperrors.NewInternal("resolve executable: " + err.Error())
}
profile := st.Profile
if v, _ := cmd.Root().PersistentFlags().GetString("profile"); strings.TrimSpace(v) != "" {
profile = strings.TrimSpace(v)
}
args := []string{"dev", "connect", "--daemon", "--unified-app-id", unifiedAppID}
if st.Channel != "" {
args = append(args, "--channel", st.Channel)
}
if st.NotifyStaffID != "" {
args = append(args, "--notify-staff-id", st.NotifyStaffID)
}
if profile != "" {
args = append(args, "--profile", profile)
}
if st.AlwaysOn {
args = append(args, "--alwayson")
}
fmt.Fprintf(cmd.OutOrStdout(), "restarting connector: dws %s\n", strings.Join(args, " "))
// Run synchronously: `--daemon` itself detaches the supervisor and
// returns quickly, so waiting here costs nothing and lets a failed
// relaunch (e.g. credential fetch error) surface as a non-zero exit
// instead of a silent success.
restartCmd := exec.Command(exe, args...)
restartCmd.Stdout = cmd.OutOrStdout()
restartCmd.Stderr = cmd.OutOrStderr()
restartCmd.Stdin = nil
if err := restartCmd.Run(); err != nil {
return apperrors.NewInternal(fmt.Sprintf("重启失败(旧守护进程已停止,连接器记录已清除);恢复请手动执行: dws %s", strings.Join(args, " ")))
}
return nil
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("robot-client-id", "", "机器人 clientId(定位守护进程)")
cmd.Flags().String("unified-app-id", "", "统一应用 ID(当未用 clientId 起守护进程时定位)")
return cmd
}
// newDevAppRobotConnectListCommand implements `dws dev connect list`: enumerate
// every connector on this machine and its health, so a developer running
// several robots sees at a glance which are alive/degraded/down without
// querying each clientId. `--json` emits the array for scripts.
func newDevAppRobotConnectListCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "列出本机所有连接器及健康状态(healthy/degraded/down);--json 供脚本消费",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, _ []string) error {
reports, err := listConnectors(time.Now())
if err != nil {
return apperrors.NewInternal(err.Error())
}
resolveAppNames(cmd, runner, reports)
w := cmd.OutOrStdout()
if jsonOut, _ := cmd.Flags().GetBool("json"); jsonOut {
data, merr := json.MarshalIndent(reports, "", " ")
if merr != nil {
return apperrors.NewInternal(merr.Error())
}
fmt.Fprintln(w, string(data))
return nil
}
if len(reports) == 0 {
fmt.Fprintln(w, "no connectors found")
return nil
}
return writeConnectListTable(w, reports)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().Bool("json", false, "以 JSON 数组输出(供脚本消费)")
return cmd
}
// resolveAppNames calls list_dev_app once to build a unifiedAppId→name map,
// then fills in AppName on each report. Failures are silent (name stays empty)
// so the list still works offline or when the API is unreachable.
func resolveAppNames(cmd *cobra.Command, runner executor.Runner, reports []connectHealthReport) {
need := false
for i := range reports {
if reports[i].UnifiedAppID != "" {
need = true
break
}
}
if !need {
return
}
nameMap, err := devAppNameMap(cmd, runner)
if err != nil || nameMap == nil {
return
}
for i := range reports {
if reports[i].UnifiedAppID == "" {
continue
}
if name, ok := nameMap[reports[i].UnifiedAppID]; ok && name != "" {
reports[i].AppName = name
}
}
}
// devAppNameMap calls list_dev_app with pagination to build a full
// unifiedAppId→appName map. It is best-effort: any error returns an empty map.
func devAppNameMap(cmd *cobra.Command, runner executor.Runner) (map[string]string, error) {
out := make(map[string]string)
cursor := ""
for page := 0; page < 20; page++ {
params := map[string]any{"pageSize": 100}
if cursor != "" {
params["cursor"] = cursor
}
inv := executor.NewHelperInvocation(cobracmd.LegacyCommandPath(cmd), devAppProduct, devAppListTool, params)
res, err := runner.Run(cmd.Context(), inv)
if err != nil {
return out, err
}
payload := devAppConnectUnwrap(res.Response)
items := devAppConnectList(payload)
for _, item := range items {
uid := devAppConnectFirst(item, "unifiedAppId", "id")
name := devAppConnectFirst(item, "name", "appName")
if uid != "" && name != "" {
out[uid] = name
}
}
hasMore := false
if v, ok := payload["hasMore"].(bool); ok {
hasMore = v
}
if !hasMore {
break
}
cursor = devAppConnectFirst(payload, "nextCursor", "cursor")
if cursor == "" {
break
}
}
return out, nil
}
// devAppConnectList extracts the array of app items from a list_dev_app payload,
// tolerating various wrapper shapes.
func devAppConnectList(payload map[string]any) []map[string]any {
if payload == nil {
return nil
}
for _, key := range []string{"items", "list", "data"} {
if arr, ok := payload[key].([]any); ok {
out := make([]map[string]any, 0, len(arr))
for _, e := range arr {
if m, ok := e.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
}
return nil
}
// connectDaemonDirKeyFromFlags resolves the daemon directory key from the // connectDaemonDirKeyFromFlags resolves the daemon directory key from the
// status/stop flags, requiring at least one identifier. // status/stop flags, requiring at least one identifier.
func connectDaemonDirKeyFromFlags(cmd *cobra.Command) (string, error) { func connectDaemonDirKeyFromFlags(cmd *cobra.Command) (string, error) {
@@ -534,3 +849,151 @@ func connectDaemonDirKeyFromFlags(cmd *cobra.Command) (string, error) {
} }
return dirKey, nil return dirKey, nil
} }
func colorConnectState(state string) string {
switch state {
case healthHealthy:
return tui.Success(state)
case healthDegraded:
return tui.Warning(state)
case healthDown, healthNotRunning:
return tui.Danger(state)
default:
return tui.Cyan(state)
}
}
func writeConnectListTable(w io.Writer, reports []connectHealthReport) error {
type col struct {
header string
width int
}
cols := []col{
{"STATE", 11},
{"APP NAME", 8},
{"CLIENT", 8},
{"PID", 6},
{"CHANNEL", 7},
{"UPTIME", 6},
}
// compute column widths from data
for _, r := range reports {
if w := tui.PlainRuneWidth(r.AppName); w > cols[1].width {
cols[1].width = w
}
if w := tui.PlainRuneWidth(r.ClientID); w > cols[2].width {
cols[2].width = w
}
if w := tui.PlainRuneWidth(r.Channel); w > cols[4].width {
cols[4].width = w
}
}
for i := range cols {
if cols[i].width > tui.MaxTableColumnWidth {
cols[i].width = tui.MaxTableColumnWidth
}
}
writeBorder := func(left, mid, right string, colorFn func(string) string) {
fmt.Fprint(w, colorFn(left))
for i, c := range cols {
if i > 0 {
fmt.Fprint(w, colorFn(mid))
}
fmt.Fprint(w, colorFn(strings.Repeat("─", c.width+2)))
}
fmt.Fprintln(w, colorFn(right))
}
writeRowCells := func(cells []string) {
fmt.Fprint(w, tui.Gray("│"))
for i, c := range cols {
cell := ""
if i < len(cells) {
cell = cells[i]
}
fmt.Fprintf(w, " %s ", tui.PadRightANSI(cell, c.width))
fmt.Fprint(w, tui.Gray("│"))
}
fmt.Fprintln(w)
}
// header
writeBorder("╭", "┬", "╮", tui.Blue)
headers := make([]string, len(cols))
for i, c := range cols {
headers[i] = tui.Brand(c.header)
}
writeRowCells(headers)
writeBorder("├", "┼", "┤", tui.Gray)
// rows
for _, r := range reports {
uptime := tui.Dim("-")
if r.UptimeSec > 0 {
uptime = tui.White((time.Duration(r.UptimeSec) * time.Second).Round(time.Second).String())
}
pid := tui.Dim("-")
if r.Pid > 0 {
pid = tui.White(fmt.Sprintf("%d", r.Pid))
}
channel := tui.Dim("-")
if r.Channel != "" {
channel = tui.White(r.Channel)
}
appName := tui.Dim("-")
if r.AppName != "" {
appName = tui.White(r.AppName)
}
writeRowCells([]string{
colorConnectState(r.State),
appName,
tui.White(r.ClientID),
pid,
channel,
uptime,
})
}
writeBorder("╰", "┴", "╯", tui.Blue)
return nil
}
// daemonNotifyStateChange sends a DingTalk message to the configured staff
// when the connector state changes. No-op when notifyStaffID is empty. It
// execs `dws chat message send` as a subprocess (fire-and-forget) so the
// supervisor is never blocked by notification delivery.
func daemonNotifyStateChange(notifyStaffID, channel, clientID, event, detail string) {
if notifyStaffID == "" {
return
}
var msg string
switch event {
case "started":
msg = fmt.Sprintf("机器人已启动 ✅\n渠道: %s\nclientId: %s", channel, clientID)
case "stopped":
msg = fmt.Sprintf("机器人已停止 ⏹️\n渠道: %s\nclientId: %s", channel, clientID)
case "crashed":
msg = fmt.Sprintf("机器人已崩溃 ⚠️\n渠道: %s\n%s\n正在自动重启...", channel, detail)
case "gave_up":
msg = fmt.Sprintf("机器人重启失败 ❌\n渠道: %s\n%s\n请检查日志后手动重启", channel, detail)
default:
return
}
exe, err := os.Executable()
if err != nil {
return
}
go func() {
cmd := exec.Command(exe, "chat", "message", "send",
"--staff-id", notifyStaffID,
"--text", msg,
"--ai-tag=false",
"--yes",
"--format", "json",
)
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
_ = cmd.Run()
}()
}
+61 -15
View File
@@ -15,6 +15,7 @@ package helpers
import ( import (
"bytes" "bytes"
"encoding/json"
"os" "os"
"strings" "strings"
"testing" "testing"
@@ -127,7 +128,7 @@ func TestDaemonStateRoundTrip(t *testing.T) {
if st, err := readDaemonState(dir); err != nil || st != nil { if st, err := readDaemonState(dir); err != nil || st != nil {
t.Fatalf("expected (nil,nil) for missing pid file, got (%v,%v)", st, err) t.Fatalf("expected (nil,nil) for missing pid file, got (%v,%v)", st, err)
} }
want := daemonState{Pid: 4242, StartUnix: time.Now().Unix(), LogPath: "/x/y.log", DirKey: "roundtrip", ClientID: "cid"} want := daemonState{Pid: 4242, StartUnix: time.Now().Unix(), LogPath: "/x/y.log", DirKey: "roundtrip", ClientID: "cid", Profile: "ding123", AlwaysOn: true}
if err := writeDaemonState(dir, want); err != nil { if err := writeDaemonState(dir, want); err != nil {
t.Fatalf("writeDaemonState: %v", err) t.Fatalf("writeDaemonState: %v", err)
} }
@@ -138,6 +139,12 @@ func TestDaemonStateRoundTrip(t *testing.T) {
if got.Pid != want.Pid || got.DirKey != want.DirKey || got.ClientID != want.ClientID || got.LogPath != want.LogPath { if got.Pid != want.Pid || got.DirKey != want.DirKey || got.ClientID != want.ClientID || got.LogPath != want.LogPath {
t.Errorf("round trip mismatch: got %+v want %+v", *got, want) t.Errorf("round trip mismatch: got %+v want %+v", *got, want)
} }
if got.Profile != want.Profile {
t.Errorf("Profile round trip: got %q want %q", got.Profile, want.Profile)
}
if got.AlwaysOn != want.AlwaysOn {
t.Errorf("AlwaysOn round trip: got %v want %v", got.AlwaysOn, want.AlwaysOn)
}
} }
func TestReadDaemonStateCorrupt(t *testing.T) { func TestReadDaemonStateCorrupt(t *testing.T) {
@@ -152,51 +159,90 @@ func TestReadDaemonStateCorrupt(t *testing.T) {
} }
} }
// seedHeartbeat writes a connector heartbeat under dirKey for status tests.
func seedHeartbeat(t *testing.T, dirKey string, hb connectHeartbeat) string {
t.Helper()
dir, err := connectDaemonDir(dirKey)
if err != nil {
t.Fatalf("connectDaemonDir: %v", err)
}
data, err := json.MarshalIndent(hb, "", " ")
if err != nil {
t.Fatalf("marshal heartbeat: %v", err)
}
if err := os.WriteFile(connectHeartbeatPath(dir), data, 0o644); err != nil {
t.Fatalf("write heartbeat: %v", err)
}
return dir
}
func TestDaemonStatusNotRunning(t *testing.T) { func TestDaemonStatusNotRunning(t *testing.T) {
connectDaemonDirOverride = t.TempDir() connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" }) t.Cleanup(func() { connectDaemonDirOverride = "" })
var buf bytes.Buffer var buf bytes.Buffer
if err := daemonStatus(&buf, "nope"); err != nil { // No daemon pid file and no connector heartbeat.
if err := daemonStatus(&buf, "nope", false); err != nil {
t.Fatalf("daemonStatus: %v", err) t.Fatalf("daemonStatus: %v", err)
} }
if !strings.Contains(buf.String(), "not running") { if !strings.Contains(buf.String(), healthNotRunning) {
t.Errorf("expected 'not running', got %q", buf.String()) t.Errorf("expected %q, got %q", healthNotRunning, buf.String())
} }
} }
func TestDaemonStatusStalePid(t *testing.T) { func TestDaemonStatusConnectorDown(t *testing.T) {
connectDaemonDirOverride = t.TempDir() connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" }) t.Cleanup(func() { connectDaemonDirOverride = "" })
dir, _ := connectDaemonDir("stale") // Heartbeat from a connector whose process is gone: down.
// pid that is essentially certain to be dead. seedHeartbeat(t, "gone", connectHeartbeat{Pid: deadPid(t), StartUnix: time.Now().Unix() - 100, ConnectedUnix: time.Now().Unix() - 90})
writeDaemonState(dir, daemonState{Pid: deadPid(t), StartUnix: time.Now().Unix(), LogPath: "/l", DirKey: "stale"})
var buf bytes.Buffer var buf bytes.Buffer
if err := daemonStatus(&buf, "stale"); err != nil { if err := daemonStatus(&buf, "gone", false); err != nil {
t.Fatalf("daemonStatus: %v", err) t.Fatalf("daemonStatus: %v", err)
} }
if !strings.Contains(buf.String(), "stale pid file") { if !strings.Contains(buf.String(), healthDown) {
t.Errorf("expected stale pid report, got %q", buf.String()) t.Errorf("expected %q, got %q", healthDown, buf.String())
} }
} }
func TestDaemonStatusRunning(t *testing.T) { func TestDaemonStatusHealthy(t *testing.T) {
connectDaemonDirOverride = t.TempDir() connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" }) t.Cleanup(func() { connectDaemonDirOverride = "" })
// Live connector (our pid) that connected: healthy. Also file a live
// supervisor to exercise the supervised label.
dir, _ := connectDaemonDir("live") dir, _ := connectDaemonDir("live")
// Use our own pid: guaranteed alive.
writeDaemonState(dir, daemonState{Pid: os.Getpid(), StartUnix: time.Now().Add(-90 * time.Second).Unix(), LogPath: "/l.log", DirKey: "live", ClientID: "cidX"}) writeDaemonState(dir, daemonState{Pid: os.Getpid(), StartUnix: time.Now().Add(-90 * time.Second).Unix(), LogPath: "/l.log", DirKey: "live", ClientID: "cidX"})
seedHeartbeat(t, "live", connectHeartbeat{Pid: os.Getpid(), Channel: "opencode", ClientID: "cidX", StartUnix: time.Now().Unix() - 90, ConnectedUnix: time.Now().Unix() - 88, LastReplyUnix: time.Now().Unix() - 5})
var buf bytes.Buffer var buf bytes.Buffer
if err := daemonStatus(&buf, "live"); err != nil { if err := daemonStatus(&buf, "live", false); err != nil {
t.Fatalf("daemonStatus: %v", err) t.Fatalf("daemonStatus: %v", err)
} }
out := buf.String() out := buf.String()
for _, want := range []string{"running", "pid:", "uptime:", "/l.log", "cidX"} { for _, want := range []string{healthHealthy, "pid:", "channel:", "opencode", "cidX", "super:"} {
if !strings.Contains(out, want) { if !strings.Contains(out, want) {
t.Errorf("status output missing %q; got %q", want, out) t.Errorf("status output missing %q; got %q", want, out)
} }
} }
} }
func TestDaemonStatusJSON(t *testing.T) {
connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" })
seedHeartbeat(t, "j", connectHeartbeat{Pid: os.Getpid(), Channel: "codex", ClientID: "cidJ", StartUnix: time.Now().Unix() - 30, ConnectedUnix: time.Now().Unix() - 28})
var buf bytes.Buffer
if err := daemonStatus(&buf, "j", true); err != nil {
t.Fatalf("daemonStatus json: %v", err)
}
var report connectHealthReport
if err := json.Unmarshal(buf.Bytes(), &report); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
}
if report.State != healthHealthy {
t.Errorf("state = %q, want %q", report.State, healthHealthy)
}
if report.Channel != "codex" {
t.Errorf("channel = %q, want codex", report.Channel)
}
}
func TestDaemonStopNotRunning(t *testing.T) { func TestDaemonStopNotRunning(t *testing.T) {
connectDaemonDirOverride = t.TempDir() connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" }) t.Cleanup(func() { connectDaemonDirOverride = "" })
+19
View File
@@ -0,0 +1,19 @@
//go:build !windows
package helpers
import (
"fmt"
"os"
"syscall"
)
func checkFDLimit() {
var rlim syscall.Rlimit
if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rlim); err != nil {
return
}
if rlim.Cur < 512 {
fmt.Fprintf(os.Stderr, "[connect][warn] 文件描述符上限 %d 偏低,多 agent 并发连接可能不稳定;建议 ulimit -n 1024\n", rlim.Cur)
}
}
@@ -0,0 +1,5 @@
//go:build windows
package helpers
func checkFDLimit() {}
+377
View File
@@ -0,0 +1,377 @@
// 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 helpers
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
// connect_health is the "does the bot actually work" side of connect
// observability. The daemon (connect_daemon.go) answers "is a supervisor
// process alive"; the single-instance lock (connect_lock.go) answers "is there
// exactly one connector". Neither answers the question a developer actually
// asks — "is my connection live and receiving?" — which is the gap raised in
// the 0701 review: a foreground `connect` prints logs and looks busy, but if
// the Stream silently dropped there is no way to tell a working-but-idle
// connector from a dead one.
//
// The connector (runStreamConnector, foreground OR daemon worker) writes a
// heartbeat file recording its pid, when it last connected, and when it last
// received/answered a message. `connect status` reads it and derives a
// healthy/degraded/down verdict, and `--json` exposes the raw signal so an
// external supervisor (launchd / systemd / pm2 / cron watchdog) can decide
// whether to restart without guessing from the process table.
//
// Scope note (honest limitation, documented in the PR): with the Stream SDK's
// WithAutoReconnect(true) and no exposed disconnect callback, we cannot cheaply
// detect a silently-deaf connection beyond "process alive + connected at least
// once". lastPushAgoSec is surfaced as data, NOT as a degraded trigger, because
// an idle bot legitimately receives nothing for hours. The single-instance lock
// already rules out the duplicate-connection failure mode, so "alive +
// connected" is a strong healthy signal in practice.
const (
connectHeartbeatFile = "heartbeat.json"
connectHeartbeatFlush = 15 * time.Second
connectHealthStalePush = 5 * time.Minute // informational threshold only
)
// connectHeartbeat is the JSON persisted by a running connector. All times are
// unix seconds; a zero value means "never happened".
type connectHeartbeat struct {
Pid int `json:"pid"`
Channel string `json:"channel,omitempty"`
ClientID string `json:"clientId,omitempty"`
StartUnix int64 `json:"startUnix"`
ConnectedUnix int64 `json:"connectedUnix,omitempty"`
LastPushUnix int64 `json:"lastPushUnix,omitempty"`
LastReplyUnix int64 `json:"lastReplyUnix,omitempty"`
LastError string `json:"lastError,omitempty"`
LastErrorUnix int64 `json:"lastErrorUnix,omitempty"`
UpdatedUnix int64 `json:"updatedUnix"`
}
// connectHealth is the in-memory writer owned by a connector. Events update it
// in memory (cheap, called on the message hot path); a background ticker bumps
// UpdatedUnix and flushes on every tick — the periodic write IS the liveness
// proof consumed by the staleness check in deriveConnectHealth, so an idle
// connector must keep writing or it would be misreported as down (pid reuse).
type connectHealth struct {
dir string
mu sync.Mutex
hb connectHeartbeat
flushedUnix int64
}
// newConnectHealth builds a health writer filed under connect/<dirKey>/. Returns
// nil when no identity is available (dirKey empty) so every call site can treat
// health as best-effort — a nil *connectHealth's methods are all no-ops.
func newConnectHealth(clientID, channel string) *connectHealth {
dirKey := daemonDirKey(clientID, "")
if dirKey == "" {
return nil
}
dir, err := connectDaemonDir(dirKey)
if err != nil {
return nil
}
now := time.Now().Unix()
return &connectHealth{
dir: dir,
hb: connectHeartbeat{
Pid: os.Getpid(),
Channel: channel,
ClientID: clientID,
StartUnix: now,
UpdatedUnix: now,
},
}
}
func (h *connectHealth) touch(mutate func(*connectHeartbeat)) {
if h == nil {
return
}
h.mu.Lock()
mutate(&h.hb)
h.hb.UpdatedUnix = time.Now().Unix()
h.mu.Unlock()
}
// onConnected records a successful Stream connect. Called after cli.Start.
func (h *connectHealth) onConnected() {
h.touch(func(hb *connectHeartbeat) { hb.ConnectedUnix = time.Now().Unix() })
}
// onPush records an inbound message accepted for forwarding.
func (h *connectHealth) onPush() {
h.touch(func(hb *connectHeartbeat) { hb.LastPushUnix = time.Now().Unix() })
}
// onReply records a reply successfully produced by the agent.
func (h *connectHealth) onReply() {
h.touch(func(hb *connectHeartbeat) { hb.LastReplyUnix = time.Now().Unix() })
}
// onError records the most recent forward/delivery error.
func (h *connectHealth) onError(err error) {
if err == nil {
return
}
h.touch(func(hb *connectHeartbeat) {
hb.LastError = truncateRunes(err.Error(), 300)
hb.LastErrorUnix = time.Now().Unix()
})
}
// start writes an initial heartbeat and launches the flush ticker. It stops and
// removes the heartbeat file when ctx is cancelled, so a graceful shutdown
// leaves no stale "healthy" file behind.
func (h *connectHealth) start(ctx context.Context) {
if h == nil {
return
}
_ = h.flush()
go func() {
t := time.NewTicker(connectHeartbeatFlush)
defer t.Stop()
for {
select {
case <-ctx.Done():
h.remove()
return
case <-t.C:
// Each tick is a liveness proof: advance UpdatedUnix even when
// nothing else changed, otherwise an idle connector's heartbeat
// goes stale and deriveConnectHealth misreports it as down.
h.touch(func(*connectHeartbeat) {})
_ = h.flush()
}
}
}()
}
// flush persists the heartbeat only when it changed since the last write.
func (h *connectHealth) flush() error {
if h == nil {
return nil
}
h.mu.Lock()
if h.hb.UpdatedUnix == h.flushedUnix {
h.mu.Unlock()
return nil
}
snapshot := h.hb
h.mu.Unlock()
data, err := json.MarshalIndent(snapshot, "", " ")
if err != nil {
return err
}
path := connectHeartbeatPath(h.dir)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, config.FilePerm); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
return err
}
h.mu.Lock()
h.flushedUnix = snapshot.UpdatedUnix
h.mu.Unlock()
return nil
}
func (h *connectHealth) remove() {
if h == nil {
return
}
_ = os.Remove(connectHeartbeatPath(h.dir))
}
func connectHeartbeatPath(dir string) string {
return dir + string(os.PathSeparator) + connectHeartbeatFile
}
// readConnectHeartbeat loads the heartbeat file. Missing file yields (nil, nil)
// so callers distinguish "no connector ran" from an I/O error.
func readConnectHeartbeat(dir string) (*connectHeartbeat, error) {
data, err := os.ReadFile(connectHeartbeatPath(dir))
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var hb connectHeartbeat
if err := json.Unmarshal(data, &hb); err != nil {
return nil, err
}
return &hb, nil
}
// Health states, ordered worst-to-best for reporting.
const (
healthNotRunning = "not_running"
healthDown = "down"
healthDegraded = "degraded"
healthHealthy = "healthy"
)
// connectHealthReport is the derived, presentation-ready verdict combining the
// connector heartbeat with the (optional) supervising daemon.
type connectHealthReport struct {
State string `json:"state"`
Pid int `json:"pid,omitempty"`
Channel string `json:"channel,omitempty"`
ClientID string `json:"clientId,omitempty"`
AppName string `json:"appName,omitempty"`
UnifiedAppID string `json:"unifiedAppId,omitempty"`
UptimeSec int64 `json:"uptimeSec,omitempty"`
ConnectedAgo int64 `json:"connectedAgoSec,omitempty"`
LastPushAgoSec int64 `json:"lastPushAgoSec,omitempty"`
LastReplyAgo int64 `json:"lastReplyAgoSec,omitempty"`
LastError string `json:"lastError,omitempty"`
Supervised bool `json:"supervised"`
Detail string `json:"detail,omitempty"`
}
// deriveConnectHealth turns the raw heartbeat (and whether a daemon supervisor
// is alive) into a verdict. now is injected so the logic is unit-testable.
func deriveConnectHealth(hb *connectHeartbeat, supervised bool, now time.Time) connectHealthReport {
nowUnix := now.Unix()
if hb == nil {
return connectHealthReport{State: healthNotRunning, Supervised: supervised,
Detail: "no connector heartbeat found"}
}
r := connectHealthReport{
Pid: hb.Pid,
Channel: hb.Channel,
ClientID: hb.ClientID,
Supervised: supervised,
LastError: hb.LastError,
}
if hb.StartUnix > 0 {
r.UptimeSec = nowUnix - hb.StartUnix
}
if hb.ConnectedUnix > 0 {
r.ConnectedAgo = nowUnix - hb.ConnectedUnix
}
if hb.LastPushUnix > 0 {
r.LastPushAgoSec = nowUnix - hb.LastPushUnix
}
if hb.LastReplyUnix > 0 {
r.LastReplyAgo = nowUnix - hb.LastReplyUnix
}
// Connector process gone: down. A supervisor (if any) will restart it.
if hb.Pid <= 0 || !processAlive(hb.Pid) {
r.State = healthDown
if supervised {
r.Detail = "connector process not alive; supervisor should restart it"
} else {
r.Detail = "connector process not alive"
}
return r
}
// Guard against pid reuse: a live pid whose heartbeat is stale (no flush
// within 2× the flush interval) is not our connector.
heartbeatStaleThreshold := int64((2 * connectHeartbeatFlush).Seconds())
if hb.UpdatedUnix > 0 && (nowUnix-hb.UpdatedUnix) > heartbeatStaleThreshold {
r.State = healthDown
r.Detail = "heartbeat stale (pid may have been reused by another process)"
return r
}
// Alive but never reached a connected state: still starting or failing to
// establish the Stream.
if hb.ConnectedUnix == 0 {
r.State = healthDegraded
r.Detail = "process alive but never established a Stream connection"
return r
}
// Alive and connected, but the most recent event was an error with no
// successful activity after it: degraded.
lastOK := hb.ConnectedUnix
if hb.LastReplyUnix > lastOK {
lastOK = hb.LastReplyUnix
}
if hb.LastErrorUnix > lastOK {
r.State = healthDegraded
r.Detail = "last activity was an error after the last success"
return r
}
r.State = healthHealthy
return r
}
// connectBaseDir returns <configDir>/connect — the directory holding one
// subdirectory per connector (keyed by dirKey). Honours the test override.
func connectBaseDir() string {
base := connectDaemonDirOverride
if base == "" {
base = config.DefaultConfigDir()
}
return filepath.Join(base, "connect")
}
// listConnectors enumerates every connector on this machine by scanning
// connect/<dirKey>/ and deriving each one's health from its heartbeat plus any
// supervising daemon. This is the multi-connector view behind `connect list` —
// the same signal `status` reports for one robot, over all of them. Directories
// with neither a heartbeat nor a daemon state are skipped (empty leftovers).
// Results are sorted by clientId for stable output. now is injected for tests.
func listConnectors(now time.Time) ([]connectHealthReport, error) {
ents, err := os.ReadDir(connectBaseDir())
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []connectHealthReport
for _, e := range ents {
if !e.IsDir() {
continue
}
dir := filepath.Join(connectBaseDir(), e.Name())
hb, herr := readConnectHeartbeat(dir)
if herr != nil {
continue // unreadable heartbeat: skip rather than fail the whole list
}
st, _ := readDaemonState(dir)
if hb == nil && st == nil {
continue // empty leftover dir
}
supervised := st != nil && st.Pid > 0 && processAlive(st.Pid)
r := deriveConnectHealth(hb, supervised, now)
if r.ClientID == "" {
r.ClientID = e.Name() // fall back to the dir key when no heartbeat identity
}
if st != nil {
r.UnifiedAppID = st.UnifiedAppID
}
out = append(out, r)
}
sort.Slice(out, func(i, j int) bool { return out[i].ClientID < out[j].ClientID })
return out, nil
}
+245
View File
@@ -0,0 +1,245 @@
// 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 helpers
import (
"encoding/json"
"errors"
"os"
"testing"
"time"
)
func TestDeriveConnectHealth(t *testing.T) {
now := time.Unix(1_000_000, 0)
alive := os.Getpid()
dead := deadPid(t)
cases := []struct {
name string
hb *connectHeartbeat
supervised bool
want string
}{
{"no heartbeat", nil, false, healthNotRunning},
{"no heartbeat but supervised", nil, true, healthNotRunning},
{
"connector dead",
&connectHeartbeat{Pid: dead, StartUnix: now.Unix() - 100, ConnectedUnix: now.Unix() - 90},
false, healthDown,
},
{
"alive never connected",
&connectHeartbeat{Pid: alive, StartUnix: now.Unix() - 5},
false, healthDegraded,
},
{
"alive and connected",
&connectHeartbeat{Pid: alive, StartUnix: now.Unix() - 100, ConnectedUnix: now.Unix() - 90, LastReplyUnix: now.Unix() - 10},
false, healthHealthy,
},
{
"idle but connected is still healthy",
&connectHeartbeat{Pid: alive, StartUnix: now.Unix() - 100000, ConnectedUnix: now.Unix() - 100000},
false, healthHealthy,
},
{
// A long-idle connector whose ticker keeps refreshing UpdatedUnix
// must stay healthy — only a genuinely stale heartbeat means down.
"idle with fresh ticker heartbeat is healthy",
&connectHeartbeat{Pid: alive, StartUnix: now.Unix() - 100000, ConnectedUnix: now.Unix() - 100000, UpdatedUnix: now.Unix() - 10},
false, healthHealthy,
},
{
"error after last success is degraded",
&connectHeartbeat{Pid: alive, StartUnix: now.Unix() - 100, ConnectedUnix: now.Unix() - 90, LastReplyUnix: now.Unix() - 50, LastErrorUnix: now.Unix() - 5, LastError: "boom"},
false, healthDegraded,
},
{
"error before last reply stays healthy",
&connectHeartbeat{Pid: alive, StartUnix: now.Unix() - 100, ConnectedUnix: now.Unix() - 90, LastErrorUnix: now.Unix() - 50, LastReplyUnix: now.Unix() - 5, LastError: "old", UpdatedUnix: now.Unix() - 5},
false, healthHealthy,
},
{
"stale heartbeat from pid reuse is down",
&connectHeartbeat{Pid: alive, StartUnix: now.Unix() - 1000, ConnectedUnix: now.Unix() - 900, UpdatedUnix: now.Unix() - 60},
false, healthDown,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := deriveConnectHealth(c.hb, c.supervised, now)
if got.State != c.want {
t.Fatalf("state = %q, want %q (detail=%q)", got.State, c.want, got.Detail)
}
if got.Supervised != c.supervised {
t.Errorf("supervised = %v, want %v", got.Supervised, c.supervised)
}
})
}
}
func TestConnectHeartbeatRoundTrip(t *testing.T) {
connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" })
h := newConnectHealth("cid-round", "opencode")
if h == nil {
t.Fatal("newConnectHealth returned nil for a valid clientId")
}
h.onConnected()
h.onPush()
h.onReply()
if err := h.flush(); err != nil {
t.Fatalf("flush: %v", err)
}
hb, err := readConnectHeartbeat(h.dir)
if err != nil {
t.Fatalf("read: %v", err)
}
if hb == nil {
t.Fatal("heartbeat not persisted")
}
if hb.Pid != os.Getpid() || hb.Channel != "opencode" || hb.ClientID != "cid-round" {
t.Errorf("unexpected heartbeat identity: %+v", hb)
}
if hb.ConnectedUnix == 0 || hb.LastPushUnix == 0 || hb.LastReplyUnix == 0 {
t.Errorf("expected all activity timestamps set: %+v", hb)
}
}
func TestConnectHeartbeatFlushSkipsUnchanged(t *testing.T) {
connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" })
h := newConnectHealth("cid-skip", "codex")
h.onConnected()
if err := h.flush(); err != nil {
t.Fatalf("flush: %v", err)
}
// Second flush with no new event must be a no-op (nothing to write).
fi1, _ := os.Stat(connectHeartbeatPath(h.dir))
if err := h.flush(); err != nil {
t.Fatalf("second flush: %v", err)
}
if h.hb.UpdatedUnix != h.flushedUnix {
t.Errorf("flushedUnix (%d) should track UpdatedUnix (%d) after flush", h.flushedUnix, h.hb.UpdatedUnix)
}
_ = fi1
}
// Regression for the v1.0.50-preview idle false-down: the flush ticker must
// advance UpdatedUnix on every tick (bare touch) so an idle connector keeps
// proving liveness; without it the heartbeat goes stale and
// deriveConnectHealth misreports the connector as down after 30s.
func TestConnectHeartbeatIdleTickKeepsFresh(t *testing.T) {
connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" })
h := newConnectHealth("cid-idle", "codex")
if h.hb.UpdatedUnix == 0 {
t.Fatal("newConnectHealth must seed UpdatedUnix so the initial flush persists")
}
h.onConnected()
if err := h.flush(); err != nil {
t.Fatalf("initial flush: %v", err)
}
if hb, err := readConnectHeartbeat(h.dir); err != nil || hb == nil {
t.Fatalf("initial heartbeat not persisted: hb=%v err=%v", hb, err)
}
// Backdate the heartbeat as if 40 idle seconds passed (beyond the 30s
// staleness threshold), then do exactly what the ticker does: a bare
// touch plus flush.
h.mu.Lock()
backdated := h.hb.UpdatedUnix - 40
h.hb.UpdatedUnix = backdated
h.flushedUnix = backdated
h.mu.Unlock()
h.touch(func(*connectHeartbeat) {})
if err := h.flush(); err != nil {
t.Fatalf("tick flush: %v", err)
}
hb, err := readConnectHeartbeat(h.dir)
if err != nil || hb == nil {
t.Fatalf("tick heartbeat not persisted: hb=%v err=%v", hb, err)
}
if hb.UpdatedUnix <= backdated {
t.Fatalf("bare tick touch must advance persisted UpdatedUnix past %d, got %d", backdated, hb.UpdatedUnix)
}
if got := deriveConnectHealth(hb, false, time.Now()); got.State != healthHealthy {
t.Fatalf("idle connector with ticker-fresh heartbeat = %q (detail=%q), want %q", got.State, got.Detail, healthHealthy)
}
}
func TestConnectHealthNilSafe(t *testing.T) {
var h *connectHealth // no clientId path yields nil
// None of these may panic.
h.onConnected()
h.onPush()
h.onReply()
h.onError(errors.New("x"))
h.start(nil)
if err := h.flush(); err != nil {
t.Fatalf("nil flush: %v", err)
}
h.remove()
}
func TestNewConnectHealthNoIdentity(t *testing.T) {
if h := newConnectHealth("", ""); h != nil {
t.Errorf("expected nil health writer with no clientId, got %+v", h)
}
}
func TestListConnectors(t *testing.T) {
connectDaemonDirOverride = t.TempDir()
t.Cleanup(func() { connectDaemonDirOverride = "" })
now := time.Unix(2_000_000, 0)
alive := os.Getpid()
dirA, _ := connectDaemonDir("dingAAA")
writeJSON(t, connectHeartbeatPath(dirA), connectHeartbeat{Pid: alive, Channel: "opencode", ClientID: "dingAAA", StartUnix: now.Unix() - 100, ConnectedUnix: now.Unix() - 90})
dirB, _ := connectDaemonDir("dingBBB")
writeJSON(t, connectHeartbeatPath(dirB), connectHeartbeat{Pid: deadPid(t), Channel: "codex", ClientID: "dingBBB", StartUnix: now.Unix() - 50, ConnectedUnix: now.Unix() - 40})
// Empty leftover dir must be skipped.
_, _ = connectDaemonDir("emptyleftover")
reports, err := listConnectors(now)
if err != nil {
t.Fatalf("listConnectors: %v", err)
}
if len(reports) != 2 {
t.Fatalf("got %d reports, want 2 (empty dir skipped): %+v", len(reports), reports)
}
if reports[0].ClientID != "dingAAA" || reports[0].State != healthHealthy {
t.Errorf("report[0] = %+v, want dingAAA healthy", reports[0])
}
if reports[1].ClientID != "dingBBB" || reports[1].State != healthDown {
t.Errorf("report[1] = %+v, want dingBBB down", reports[1])
}
}
func writeJSON(t *testing.T, path string, v any) {
t.Helper()
data, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatal(err)
}
}
+280
View File
@@ -22,6 +22,7 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"github.com/google/uuid" "github.com/google/uuid"
@@ -47,6 +48,212 @@ func pictureDownloadCode(content interface{}) string {
return "" return ""
} }
// fileInboundInfo carries everything a msgtype="file" callback might expose.
// Client-sent files (a user attaching a file in the DingTalk client) surface
// DownloadCode + FileName; API-sent files (`dws chat message send --msg-type
// file --dentry-id --space-id`) surface DentryID + SpaceID + FileName +
// FileType + FileSize + FilePath and NO DownloadCode. Both shapes have to be
// recognisable or the connector silently drops legitimate file messages.
type fileInboundInfo struct {
DownloadCode string
FileName string
FileType string
FilePath string
DentryID int64
SpaceID int64
FileSize int64
}
func (f fileInboundInfo) hasActionable() bool {
return strings.TrimSpace(f.DownloadCode) != "" || (f.DentryID != 0 && f.SpaceID != 0)
}
// parseFileInbound reads every relevant field out of a file callback's
// content payload (msgtype="file"). The content is a loosely-typed
// map[string]interface{}; numeric fields (dentryId/spaceId/fileSize) can be
// JSON strings or numbers depending on which endpoint sent the message, so
// both branches are handled.
func parseFileInbound(content interface{}) fileInboundInfo {
info := fileInboundInfo{}
m, ok := content.(map[string]interface{})
if !ok {
return info
}
for _, key := range []string{"downloadCode", "fileDownloadCode"} {
if v, ok := m[key].(string); ok && strings.TrimSpace(v) != "" {
info.DownloadCode = strings.TrimSpace(v)
break
}
}
if v, ok := m["fileName"].(string); ok {
info.FileName = strings.TrimSpace(v)
}
if v, ok := m["fileType"].(string); ok {
info.FileType = strings.TrimSpace(v)
}
if v, ok := m["filePath"].(string); ok {
info.FilePath = strings.TrimSpace(v)
}
info.DentryID = readInt64Field(m, "dentryId", "dentryID")
info.SpaceID = readInt64Field(m, "spaceId", "spaceID")
info.FileSize = readInt64Field(m, "fileSize", "size")
if info.FileName == "" {
info.FileName = "未知文件"
}
return info
}
// readInt64Field pulls an int64 out of the loose content map under any of the
// provided keys, tolerating JSON string / float64 / int64 / json.Number.
func readInt64Field(m map[string]interface{}, keys ...string) int64 {
for _, key := range keys {
v, ok := m[key]
if !ok {
continue
}
switch t := v.(type) {
case string:
if s := strings.TrimSpace(t); s != "" {
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
return n
}
}
case float64:
return int64(t)
case int64:
return t
case int:
return int64(t)
case json.Number:
if n, err := t.Int64(); err == nil {
return n
}
}
}
return 0
}
// fileDownloadInfo preserves the two-value shape used by legacy callers that
// only care about the downloadCode / fileName pair.
func fileDownloadInfo(content interface{}) (downloadCode, fileName string) {
info := parseFileInbound(content)
return info.DownloadCode, info.FileName
}
// summarizeContent renders the callback content into a short one-line string
// for stderr diagnostics (used when a file/other callback is being dropped
// so the operator can tell why after the fact).
func summarizeContent(content interface{}) string {
if content == nil {
return "<nil>"
}
b, err := json.Marshal(content)
if err != nil {
return fmt.Sprintf("<unmarshalable:%v>", err)
}
s := string(b)
if len(s) > 400 {
s = s[:400] + "…"
}
return s
}
// extractCallbackText pulls the visible text out of a structured-text callback
// payload (msgtype=richText / markdown / etc.) for the case where the SDK's
// data.Text.Content is empty. This matters because `dws chat message send
// --group ... --text ...` sends msgType="markdown" by default, and DingTalk's
// stream callback for markdown messages routinely leaves data.Text.Content
// blank while stashing the real body in data.Content (loosely-typed).
// Returns "" if no text-shaped field is found.
func extractCallbackText(content interface{}) string {
switch v := content.(type) {
case string:
return strings.TrimSpace(v)
case map[string]interface{}:
// Common shapes: {"text":"..."}, {"title":"...","text":"..."},
// {"content":"..."}, richText {"richText":[{"text":"..."}]}.
for _, key := range []string{"text", "content", "markdown", "title"} {
if s, ok := v[key].(string); ok && strings.TrimSpace(s) != "" {
return strings.TrimSpace(s)
}
}
if arr, ok := v["richText"].([]interface{}); ok {
var b strings.Builder
for _, item := range arr {
if m, ok := item.(map[string]interface{}); ok {
if s, ok := m["text"].(string); ok {
b.WriteString(s)
}
}
}
if s := strings.TrimSpace(b.String()); s != "" {
return s
}
}
// interactiveCard: another bot @-mentioning this bot arrives as
// msgtype="interactiveCard" with the body nested in
// content.cardContent[].children[].value (elementType TEXT). The SDK
// leaves Text.Content blank, so without this the connector drops a
// legitimate bot-to-bot @ message.
if s := extractCardContentText(v["cardContent"]); s != "" {
return s
}
}
return ""
}
// cardContentLeaves flattens an interactiveCard cardContent tree into its
// ordered TEXT leaf values. Shape (verified live): cardContent is an array of
// blocks, each with a "children" array of {elementType:"TEXT", value:"..."}
// leaves; nested blocks recurse via their own "children".
func cardContentLeaves(v interface{}) []string {
arr, ok := v.([]interface{})
if !ok {
return nil
}
var leaves []string
var walk func(items []interface{})
walk = func(items []interface{}) {
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
if s, ok := m["value"].(string); ok && s != "" {
leaves = append(leaves, s)
}
if kids, ok := m["children"].([]interface{}); ok {
walk(kids)
}
}
}
walk(arr)
return leaves
}
// extractCardContentText joins all interactiveCard leaves — the raw body,
// mention included. Used by the generic extractCallbackText fallback.
func extractCardContentText(v interface{}) string {
return strings.TrimSpace(strings.Join(cardContentLeaves(v), ""))
}
// extractInteractiveCardText returns the interactiveCard body with the leading
// @-mention removed. A bot @-ing another bot renders the mention as its own
// leading "@name" TEXT leaf (the display name may itself contain spaces, so we
// drop by leaf boundary, not by whitespace), followed by the real message in
// the next leaf. Returns "" when there is nothing beyond the mention.
func extractInteractiveCardText(content interface{}) string {
m, ok := content.(map[string]interface{})
if !ok {
return ""
}
leaves := cardContentLeaves(m["cardContent"])
for len(leaves) > 0 && strings.HasPrefix(strings.TrimSpace(leaves[0]), "@") {
leaves = leaves[1:]
}
return strings.TrimSpace(strings.Join(leaves, ""))
}
// downloadMessageFile resolves a chatbot media callback (picture etc.) to a // downloadMessageFile resolves a chatbot media callback (picture etc.) to a
// local temp file via the robot messageFiles/download API. Error-screenshot // local temp file via the robot messageFiles/download API. Error-screenshot
// questions are the top Q&A inbound; without this the connector silently // questions are the top Q&A inbound; without this the connector silently
@@ -94,6 +301,79 @@ func (c *aiCardClient) downloadMessageFile(ctx context.Context, robotCode, downl
return dest, nil return dest, nil
} }
// getUserUnionID resolves a staffId (userId) to unionId via the contact API.
// The connector needs unionId to call the storage/drive download APIs.
func (c *aiCardClient) getUserUnionID(ctx context.Context, userID string) (string, error) {
raw, err := c.callRaw(ctx, http.MethodGet, "/v1.0/contact/users/"+userID, nil)
if err != nil {
return "", fmt.Errorf("contact/users/%s: %w", userID, err)
}
var parsed struct {
UnionID string `json:"unionId"`
}
if err := json.Unmarshal([]byte(raw), &parsed); err != nil || strings.TrimSpace(parsed.UnionID) == "" {
return "", fmt.Errorf("contact/users/%s 未返回 unionId: %s", userID, truncateRunes(raw, 200))
}
return parsed.UnionID, nil
}
// downloadDentryFile downloads a file from DingTalk storage by numeric
// dentryId + spaceId (the shape API-sent file callbacks provide). It resolves
// download info via the v2.0 storage API and saves the file to a local temp
// path, returning the path.
func (c *aiCardClient) downloadDentryFile(ctx context.Context, spaceID, dentryID int64, unionID, fileName string) (string, error) {
path := fmt.Sprintf("/v2.0/storage/spaces/%d/dentries/%d/getDownloadInfo", spaceID, dentryID)
raw, err := c.callRaw(ctx, http.MethodPost, path, map[string]any{
"unionId": unionID,
})
if err != nil {
return "", fmt.Errorf("getDownloadInfo spaceId=%d dentryId=%d: %w", spaceID, dentryID, err)
}
var parsed struct {
ResourceURL string `json:"resourceUrl"`
HeadersMap map[string]string `json:"headers"`
}
if err := json.Unmarshal([]byte(raw), &parsed); err != nil || strings.TrimSpace(parsed.ResourceURL) == "" {
return "", fmt.Errorf("getDownloadInfo 未返回 resourceUrl: %s", truncateRunes(raw, 200))
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.ResourceURL, nil)
if err != nil {
return "", err
}
for k, v := range parsed.HeadersMap {
req.Header.Set(k, v)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", fmt.Errorf("钉盘文件下载 HTTP %d", resp.StatusCode)
}
dir := filepath.Join(os.TempDir(), "dws-connect-media")
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
ext := filepath.Ext(fileName)
if ext == "" {
ext = mediaExt(parsed.ResourceURL, resp.Header.Get("Content-Type"))
}
dest := filepath.Join(dir, uuid.NewString()+ext)
f, err := os.Create(dest)
if err != nil {
return "", err
}
defer f.Close()
if _, err := io.Copy(f, io.LimitReader(resp.Body, mediaMaxDownloadBytes)); err != nil {
_ = os.Remove(dest)
return "", err
}
return dest, nil
}
// mediaExt picks a file extension from the response content type, falling // mediaExt picks a file extension from the response content type, falling
// back to the URL path, then ".png" (DingTalk screenshots default to png). // back to the URL path, then ".png" (DingTalk screenshots default to png).
func mediaExt(rawURL, contentType string) string { func mediaExt(rawURL, contentType string) string {
+270
View File
@@ -42,6 +42,81 @@ func TestPictureDownloadCode(t *testing.T) {
} }
} }
// TestExtractCallbackText covers the markdown / richText fallback path used
// when SDK data.Text.Content is empty. This is the recovery path for
// `dws chat message send --group ... --text ...` (defaults to msgType=markdown)
// which otherwise gets silently dropped by the connector.
func TestExtractCallbackText(t *testing.T) {
cases := []struct {
name string
content interface{}
want string
}{
{"raw string", "hello", "hello"},
{"text field", map[string]interface{}{"text": "hi"}, "hi"},
{"text preferred over title", map[string]interface{}{"title": "标题", "text": "body"}, "body"},
{"content field", map[string]interface{}{"content": "raw"}, "raw"},
{"markdown field", map[string]interface{}{"markdown": "**bold**"}, "**bold**"},
{"title only", map[string]interface{}{"title": "只有标题"}, "只有标题"},
{"richText array", map[string]interface{}{"richText": []interface{}{
map[string]interface{}{"text": "part1 "},
map[string]interface{}{"text": "part2"},
}}, "part1 part2"},
{"whitespace trimmed", map[string]interface{}{"text": " spaced "}, "spaced"},
{"nil returns empty", nil, ""},
{"unknown shape returns empty", map[string]interface{}{"foo": "bar"}, ""},
{"empty text falls through", map[string]interface{}{"text": "", "content": "backup"}, "backup"},
// interactiveCard (bot @-mentioning this bot): the real payload shape
// captured live — body nested in cardContent[].children[].value.
{"interactiveCard cardContent", map[string]interface{}{"cardContent": []interface{}{
map[string]interface{}{"elementType": "RICHTEXT", "children": []interface{}{
map[string]interface{}{"elementType": "TEXT", "value": "@claudecode 助手"},
map[string]interface{}{"elementType": "TEXT", "value": " 请从 1 数到 10"},
}},
}}, "@claudecode 助手 请从 1 数到 10"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := extractCallbackText(tc.content); got != tc.want {
t.Fatalf("got %q, want %q", got, tc.want)
}
})
}
}
// TestExtractInteractiveCardText covers the bot→bot @ card: the leading
// mention leaf (whose display name may contain spaces) is dropped by leaf
// boundary, leaving the clean instruction.
func TestExtractInteractiveCardText(t *testing.T) {
card := func(leaves ...string) interface{} {
kids := make([]interface{}, 0, len(leaves))
for _, l := range leaves {
kids = append(kids, map[string]interface{}{"elementType": "TEXT", "value": l})
}
return map[string]interface{}{"cardContent": []interface{}{
map[string]interface{}{"elementType": "RICHTEXT", "children": kids},
}}
}
cases := []struct {
name string
content interface{}
want string
}{
{"mention leaf dropped", card("@claudecode 助手", " 请从 1 数到 10"), "请从 1 数到 10"},
{"no mention", card("直接说的话"), "直接说的话"},
{"only mention", card("@claudecode 助手"), ""},
{"non-map content", "plain", ""},
{"nil content", nil, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := extractInteractiveCardText(tc.content); got != tc.want {
t.Fatalf("got %q, want %q", got, tc.want)
}
})
}
}
// TestDownloadMessageFile drives the full resolve-then-fetch flow against a // TestDownloadMessageFile drives the full resolve-then-fetch flow against a
// fake API: token → messageFiles/download (must carry robotCode+downloadCode) // fake API: token → messageFiles/download (must carry robotCode+downloadCode)
// → presigned GET → local temp file. // → presigned GET → local temp file.
@@ -117,3 +192,198 @@ func TestMediaExt(t *testing.T) {
} }
} }
} }
// TestParseFileInbound covers both callback shapes so the regression that
// dropped every API-sent file (dentryId/spaceId, no downloadCode) can't
// silently return: (a) client-sent shape carrying downloadCode + fileName,
// (b) API-sent shape carrying dentryId + spaceId as JSON string OR number,
// (c) mixed / unknown / nil shapes must degrade to hasActionable()=false.
func TestParseFileInbound(t *testing.T) {
cases := []struct {
name string
content interface{}
wantCode string
wantName string
wantDentry int64
wantSpace int64
wantActionable bool
}{
{
name: "client-sent downloadCode",
content: map[string]interface{}{"downloadCode": "dc-1", "fileName": "screenshot.png"},
wantCode: "dc-1",
wantName: "screenshot.png",
wantActionable: true,
},
{
name: "client-sent fileDownloadCode alias",
content: map[string]interface{}{"fileDownloadCode": "dc-2", "fileName": "log.txt"},
wantCode: "dc-2",
wantName: "log.txt",
wantActionable: true,
},
{
name: "API-sent dentryId/spaceId as numbers",
content: map[string]interface{}{
"dentryId": float64(123456789),
"spaceId": float64(987654321),
"fileName": "report.pdf",
},
wantName: "report.pdf",
wantDentry: 123456789,
wantSpace: 987654321,
wantActionable: true,
},
{
name: "API-sent dentryId/spaceId as strings (real callback shape)",
content: map[string]interface{}{
"dentryId": "11111",
"spaceId": "22222",
"fileName": "spec.docx",
},
wantName: "spec.docx",
wantDentry: 11111,
wantSpace: 22222,
wantActionable: true,
},
{
name: "default fileName when missing",
content: map[string]interface{}{"downloadCode": "dc-3"},
wantCode: "dc-3",
wantName: "未知文件",
wantActionable: true,
},
{
name: "no downloadCode + only dentry (missing space) is NOT actionable",
content: map[string]interface{}{"dentryId": float64(1), "fileName": "x"},
wantName: "x",
wantDentry: 1,
wantActionable: false,
},
{
name: "wrong type returns empty",
content: "not-a-map",
wantName: "",
wantActionable: false,
},
{
name: "nil returns empty",
content: nil,
wantName: "",
wantActionable: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := parseFileInbound(tc.content)
if got.DownloadCode != tc.wantCode {
t.Errorf("DownloadCode = %q, want %q", got.DownloadCode, tc.wantCode)
}
if got.FileName != tc.wantName {
t.Errorf("FileName = %q, want %q", got.FileName, tc.wantName)
}
if got.DentryID != tc.wantDentry {
t.Errorf("DentryID = %d, want %d", got.DentryID, tc.wantDentry)
}
if got.SpaceID != tc.wantSpace {
t.Errorf("SpaceID = %d, want %d", got.SpaceID, tc.wantSpace)
}
if got.hasActionable() != tc.wantActionable {
t.Errorf("hasActionable() = %v, want %v", got.hasActionable(), tc.wantActionable)
}
})
}
}
// TestFileDownloadInfoBackCompat verifies the legacy two-value wrapper still
// works so any other caller relying on it isn't broken by the refactor.
func TestFileDownloadInfoBackCompat(t *testing.T) {
code, name := fileDownloadInfo(map[string]interface{}{
"downloadCode": "dc-legacy",
"fileName": "legacy.doc",
})
if code != "dc-legacy" || name != "legacy.doc" {
t.Fatalf("legacy wrapper = %q/%q, want dc-legacy/legacy.doc", code, name)
}
}
func TestSummarizeContent(t *testing.T) {
if got := summarizeContent(nil); got != "<nil>" {
t.Fatalf("nil summary = %q", got)
}
got := summarizeContent(map[string]interface{}{"dentryId": "1", "fileName": "x"})
if !strings.Contains(got, "dentryId") || !strings.Contains(got, "fileName") {
t.Fatalf("summary %q missing keys", got)
}
}
func TestGetUserUnionID(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/v1.0/oauth2/accessToken", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"accessToken": "tok-1", "expireIn": 7200})
})
mux.HandleFunc("/v1.0/contact/users/user-123", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("want GET, got %s", r.Method)
}
if r.Header.Get("x-acs-dingtalk-access-token") != "tok-1" {
t.Error("missing access token")
}
_ = json.NewEncoder(w).Encode(map[string]any{"unionId": "union-abc"})
})
srv := httptest.NewServer(mux)
defer srv.Close()
withCardAPIBase(t, srv.URL)
c := newAICardClient("ding-client", "ding-secret", "")
uid, err := c.getUserUnionID(context.Background(), "user-123")
if err != nil {
t.Fatalf("getUserUnionID: %v", err)
}
if uid != "union-abc" {
t.Fatalf("unionId = %q, want union-abc", uid)
}
}
func TestDownloadDentryFile(t *testing.T) {
mux := http.NewServeMux()
var srv *httptest.Server
mux.HandleFunc("/v1.0/oauth2/accessToken", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"accessToken": "tok-1", "expireIn": 7200})
})
mux.HandleFunc("/v2.0/storage/spaces/999/dentries/123/getDownloadInfo", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("want POST, got %s", r.Method)
}
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if body["unionId"] != "union-abc" {
t.Errorf("unionId = %v, want union-abc", body["unionId"])
}
_ = json.NewEncoder(w).Encode(map[string]any{
"resourceUrl": srv.URL + "/dl/report.pdf",
})
})
mux.HandleFunc("/dl/report.pdf", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/pdf")
_, _ = w.Write([]byte("%PDF-fake"))
})
srv = httptest.NewServer(mux)
defer srv.Close()
withCardAPIBase(t, srv.URL)
c := newAICardClient("ding-client", "ding-secret", "")
path, err := c.downloadDentryFile(context.Background(), 999, 123, "union-abc", "report.pdf")
if err != nil {
t.Fatalf("downloadDentryFile: %v", err)
}
defer os.Remove(path)
if !strings.HasSuffix(path, ".pdf") {
t.Fatalf("path = %q, want .pdf suffix", path)
}
raw, err := os.ReadFile(path)
if err != nil || string(raw) != "%PDF-fake" {
t.Fatalf("file content = %q, %v", raw, err)
}
}
+4 -5
View File
@@ -96,7 +96,7 @@ func (f *opencodeForwarder) forward(ctx context.Context, convID, text string) (s
} }
func (f *opencodeForwarder) forwardStream(ctx context.Context, convID, text string, _ func(string)) (string, error) { func (f *opencodeForwarder) forwardStream(ctx context.Context, convID, text string, _ func(string)) (string, error) {
ctx, cancel := context.WithTimeout(ctx, f.timeout) ctx, cancel := applyTimeout(ctx, f.timeout)
defer cancel() defer cancel()
client, err := f.server.ensure(ctx) client, err := f.server.ensure(ctx)
@@ -204,10 +204,9 @@ func newOpencodeServer(bin string, env []string, workDir string) *opencodeServer
bin: bin, bin: bin,
env: env, env: env,
workDir: workDir, workDir: workDir,
// No client-level Timeout: a turn can legitimately run for minutes, so the // No client-level Timeout: a turn can legitimately run for minutes.
// per-request ctx (forwardStream's f.timeout, default 300s) governs instead. // f.timeout (from --agent-timeout / DWS_AGENT_TIMEOUT_MS, default 0 =
// A hardcoded 30s here used to abort long agent replies mid-flight with // no limit) governs via the per-request ctx when set.
// "Client.Timeout exceeded while awaiting headers".
httpClient: &http.Client{}, httpClient: &http.Client{},
} }
} }
+65 -5
View File
@@ -16,6 +16,7 @@ package helpers
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"path/filepath" "path/filepath"
@@ -246,17 +247,17 @@ func TestOpencodeSessionsPersist(t *testing.T) {
} }
} }
// TestNewOpencodeServerHasNoClientTimeout pins the fix: the shared HTTP client // TestNewOpencodeServerHasNoClientTimeout pins: the shared HTTP client
// must not carry an overall deadline, otherwise long agent turns get aborted // must not carry an overall deadline — long agent turns would be aborted
// with "Client.Timeout exceeded while awaiting headers". The per-turn ctx is // mid-flight. The per-turn ctx (f.timeout, default 0 = no limit) governs
// the only thing allowed to bound a message round-trip. // only when the user explicitly sets --agent-timeout / DWS_AGENT_TIMEOUT_MS.
func TestNewOpencodeServerHasNoClientTimeout(t *testing.T) { func TestNewOpencodeServerHasNoClientTimeout(t *testing.T) {
s := newOpencodeServer("opencode", nil, "") s := newOpencodeServer("opencode", nil, "")
if s.httpClient == nil { if s.httpClient == nil {
t.Fatal("httpClient must be initialized") t.Fatal("httpClient must be initialized")
} }
if s.httpClient.Timeout != 0 { if s.httpClient.Timeout != 0 {
t.Fatalf("opencode http client Timeout = %s, want 0 (per-turn ctx governs long agent replies)", s.httpClient.Timeout) t.Fatalf("opencode http client Timeout = %s, want 0 (no client-level cap)", s.httpClient.Timeout)
} }
} }
@@ -314,6 +315,65 @@ func TestOpencodeForwarderMessageGovernedByTurnCtx(t *testing.T) {
} }
} }
// TestOpencodeForwarderContextDeadlineExceededRepro reproduces the error seen
// in the wild: user sends "run tech report", the opencode server processes for
// longer than the user-configured turn timeout, and the context deadline kills
// the POST /session/:id/message. The error must surface "context deadline
// exceeded" so the higher-level formatter produces a meaningful reply.
func TestOpencodeForwarderContextDeadlineExceededRepro(t *testing.T) {
dir := t.TempDir()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/global/health":
_, _ = w.Write([]byte(`{"healthy":true}`))
case r.URL.Path == "/session":
_, _ = w.Write([]byte(`{"id":"ses_1065c16edffeC46Oie6v9WY11l"}`))
case r.URL.Path == "/session/ses_1065c16edffeC46Oie6v9WY11l/message":
// Simulate a long-running agent that never responds within the turn budget.
// Fallback timeout ensures ts.Close() does not hang waiting on the handler.
select {
case <-r.Context().Done():
case <-time.After(1 * time.Second):
}
default:
http.NotFound(w, r)
}
}))
defer ts.Close()
f := &opencodeForwarder{
bin: "opencode",
timeout: 100 * time.Millisecond, // short turn budget to trigger deadline quickly
workDir: dir,
sessions: newOpencodeSessions(filepath.Join(dir, "s.json")),
server: &opencodeServer{baseURL: ts.URL, httpClient: &http.Client{}},
}
_, err := f.forwardStream(context.Background(), "conv-1", "run tech report", nil)
if err == nil {
t.Fatal("expected context deadline exceeded error, got nil")
}
errStr := err.Error()
if !strings.Contains(errStr, "context deadline exceeded") {
t.Fatalf("error = %q, want it to contain 'context deadline exceeded'", errStr)
}
if !strings.Contains(errStr, "/session/ses_1065c16edffeC46Oie6v9WY11l/message") {
t.Fatalf("error = %q, want it to contain the session message path", errStr)
}
// Verify the higher-level format matches the screenshot.
formatted := fmt.Sprintf("(opencode 调用失败:%v)", err)
if !strings.Contains(formatted, "context deadline exceeded") {
t.Fatalf("formatted reply = %q, want 'context deadline exceeded'", formatted)
}
if !strings.HasPrefix(formatted, "(opencode 调用失败:") {
t.Fatalf("formatted reply = %q, want prefix '(opencode 调用失败:'", formatted)
}
}
// opencodeForwarder must satisfy sessionClearer so /clear gets a real delete. // opencodeForwarder must satisfy sessionClearer so /clear gets a real delete.
var _ sessionClearer = (*opencodeForwarder)(nil) var _ sessionClearer = (*opencodeForwarder)(nil)
+1 -1
View File
@@ -79,7 +79,7 @@ func (f *qoderStreamForwarder) forward(ctx context.Context, convID, text string)
} }
func (f *qoderStreamForwarder) forwardStream(ctx context.Context, convID, text string, onDelta func(string)) (string, error) { func (f *qoderStreamForwarder) forwardStream(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
ctx, cancel := context.WithTimeout(ctx, f.timeout) ctx, cancel := applyTimeout(ctx, f.timeout)
defer cancel() defer cancel()
f.mu.Lock() f.mu.Lock()
+171 -14
View File
@@ -16,6 +16,7 @@ package helpers
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -187,6 +188,9 @@ type connectAgentOptions struct {
// "remember" asks once per action kind then reuses that decision. Sourced // "remember" asks once per action kind then reuses that decision. Sourced
// from RoleConfig.confirm_policy; empty = manual. // from RoleConfig.confirm_policy; empty = manual.
ConfirmPolicy string ConfirmPolicy string
// Timeout caps each agent turn (--agent-timeout seconds /
// DWS_AGENT_TIMEOUT_MS milliseconds). 0 = no limit (default).
Timeout time.Duration
} }
// isStreamBridgeChannel reports whether a channel is wired through the Go-native // isStreamBridgeChannel reports whether a channel is wired through the Go-native
@@ -239,7 +243,7 @@ func (f *execForwarder) label() string {
} }
func (f *execForwarder) forward(ctx context.Context, convID, text string) (string, error) { func (f *execForwarder) forward(ctx context.Context, convID, text string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, f.timeout) ctx, cancel := applyTimeout(ctx, f.timeout)
defer cancel() defer cancel()
// Session args go right after the binary, before the spec tail — some specs // Session args go right after the binary, before the spec tail — some specs
// (qoder) end the tail with `-p` so the prompt must stay the trailing // (qoder) end the tail with `-p` so the prompt must stay the trailing
@@ -765,7 +769,10 @@ func resolveExecAgent(channel string) (argv []string, env []string, err error) {
// dependency on a live interactive session. opts applies the user-facing agent // dependency on a live interactive session. opts applies the user-facing agent
// tuning (--agent-model / --agent-workdir / --agent-memory). // tuning (--agent-model / --agent-workdir / --agent-memory).
func forwarderForChannel(channel, clientID string, opts connectAgentOptions) (forwarder, error) { func forwarderForChannel(channel, clientID string, opts connectAgentOptions) (forwarder, error) {
timeout := envDurationMS("DWS_AGENT_TIMEOUT_MS", 300*time.Second) timeout := opts.Timeout
if timeout <= 0 {
timeout = envDurationMS("DWS_AGENT_TIMEOUT_MS", 0)
}
spec, ok := agentSpecs[channel] spec, ok := agentSpecs[channel]
if !ok { if !ok {
return nil, apperrors.NewValidation(fmt.Sprintf("渠道 %q 不是 stream-bridge 渠道,无 forwarder", channel)) return nil, apperrors.NewValidation(fmt.Sprintf("渠道 %q 不是 stream-bridge 渠道,无 forwarder", channel))
@@ -909,6 +916,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
if extras == nil { if extras == nil {
extras = &connectExtras{} extras = &connectExtras{}
} }
checkFDLimit()
if closer, ok := fwd.(forwarderCloser); ok { if closer, ok := fwd.(forwarderCloser); ok {
defer func() { defer func() {
if err := closer.close(); err != nil { if err := closer.close(); err != nil {
@@ -925,6 +933,12 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
} }
defer release() defer release()
// Health heartbeat: record connect/receive/reply/error so `connect status`
// can tell a live connector from a dead one (see connect_health.go). Nil
// when no clientId identity is available; all calls below are no-ops then.
health := newConnectHealth(clientID, channel)
health.start(ctx)
streamLoggerOnce.Do(func() { sdklogger.SetLogger(streamSDKLogger{}) }) streamLoggerOnce.Do(func() { sdklogger.SetLogger(streamSDKLogger{}) })
replier := chatbot.NewChatbotReplier() replier := chatbot.NewChatbotReplier()
dedup := newMsgDedup(10000) dedup := newMsgDedup(10000)
@@ -936,19 +950,58 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
mediaCli = newAICardClient(clientID, clientSecret, "") mediaCli = newAICardClient(clientID, clientSecret, "")
} }
keepAlive := envDurationMS("DWS_CONNECT_KEEPALIVE_MS", 30000)
fmt.Fprintf(os.Stderr, "[connect] keepAlive=%s autoReconnect=true\n", keepAlive)
cli := client.NewStreamClient( cli := client.NewStreamClient(
client.WithAppCredential(client.NewAppCredentialConfig(clientID, clientSecret)), client.WithAppCredential(client.NewAppCredentialConfig(clientID, clientSecret)),
client.WithAutoReconnect(true), client.WithAutoReconnect(true),
client.WithKeepAlive(keepAlive),
) )
cli.RegisterChatBotCallbackRouter(func(_ context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) { cli.RegisterChatBotCallbackRouter(func(_ context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
text := strings.TrimSpace(data.Text.Content) text := strings.TrimSpace(data.Text.Content)
msgtype := strings.TrimSpace(data.Msgtype)
// Picture messages carry no text — their payload is a downloadCode // Picture messages carry no text — their payload is a downloadCode
// resolved to a local file in the forward goroutine below. // resolved to a local file in the forward goroutine below.
picCode := "" picCode := ""
if strings.EqualFold(strings.TrimSpace(data.Msgtype), "picture") { if strings.EqualFold(msgtype, "picture") {
picCode = pictureDownloadCode(data.Content) picCode = pictureDownloadCode(data.Content)
} }
if (text == "" && picCode == "") || data.SessionWebhook == "" { // File callbacks come in two shapes: client-sent files carry a
// downloadCode; API-sent files (`dws chat message send --msg-type file
// --dentry-id --space-id`) carry dentryId + spaceId instead and have
// NO downloadCode. Both have to be recognisable or legit file messages
// get silently dropped below.
var fileInfo fileInboundInfo
if strings.EqualFold(msgtype, "file") {
fileInfo = parseFileInbound(data.Content)
}
// Structured-text fallback: DingTalk leaves data.Text.Content blank on
// markdown / richText callbacks (the body ships in data.Content). Without
// this, `dws chat message send --group ... --text ...` — which defaults
// to msgType=markdown — hits the drop branch below and the bot looks
// dead to the sender.
if text == "" && picCode == "" {
// interactiveCard (a bot @-mentioning this bot) nests the body in
// content.cardContent and carries the mention as its own leading
// leaf; the leaf-aware extractor drops it so the agent gets the
// clean instruction. Other structured-text shapes use the generic
// extractor.
if strings.EqualFold(msgtype, "interactiveCard") {
text = extractInteractiveCardText(data.Content)
}
if text == "" {
if fallback := extractCallbackText(data.Content); fallback != "" {
text = fallback
}
}
}
if (text == "" && picCode == "" && !fileInfo.hasActionable()) || data.SessionWebhook == "" {
// Observability: silent drops are the #1 reason a working connector
// looks dead. Log msgtype + a payload summary so an unhandled shape
// (e.g. new-style file callback without downloadCode) shows up in
// stderr instead of being invisible.
fmt.Fprintf(os.Stderr, "[connect] 丢弃消息 msgtype=%q staffId=%s convId=%s msgId=%s content=%s (无正文/图片/可下载文件或 sessionWebhook 为空)\n",
msgtype, data.SenderStaffId, data.ConversationId, data.MsgId, summarizeContent(data.Content))
return []byte(""), nil return []byte(""), nil
} }
// Drop redelivered duplicates so a retried message is not replied twice. // Drop redelivered duplicates so a retried message is not replied twice.
@@ -978,11 +1031,14 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
sender = strings.TrimSpace(data.SenderStaffId) sender = strings.TrimSpace(data.SenderStaffId)
} }
shown := text shown := text
if shown == "" { if shown == "" && picCode != "" {
shown = "[图片]" shown = "[图片]"
} else if shown == "" && fileInfo.hasActionable() {
shown = "[文件: " + fileInfo.FileName + "]"
} }
fmt.Fprintf(os.Stderr, "[connect] 收到 @%s: %s (convType=%s convId=%s staffId=%s msgId=%s)\n", fmt.Fprintf(os.Stderr, "[connect] 收到 @%s: %s (convType=%s convId=%s staffId=%s msgId=%s)\n",
sender, truncateRunes(shown, 80), data.ConversationType, data.ConversationId, data.SenderStaffId, data.MsgId) sender, truncateRunes(shown, 80), data.ConversationType, data.ConversationId, data.SenderStaffId, data.MsgId)
health.onPush()
// Ack-first: return now, reply asynchronously via sessionWebhook (which is // Ack-first: return now, reply asynchronously via sessionWebhook (which is
// independent of the Stream ack). Use a background context so the in-flight // independent of the Stream ack). Use a background context so the in-flight
// forward is not cancelled by the SDK when this callback returns. // forward is not cancelled by the SDK when this callback returns.
@@ -1057,6 +1113,61 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
prompt = prompt + "\n(用户同时附了一张图片,本地路径 " + localPath + ",请结合图片内容回答。)" prompt = prompt + "\n(用户同时附了一张图片,本地路径 " + localPath + ",请结合图片内容回答。)"
} }
} }
if fileInfo.hasActionable() {
fileName := fileInfo.FileName
var localPath string
var derr error
if fileInfo.DownloadCode != "" {
localPath, derr = mediaCli.downloadMessageFile(context.Background(), clientID, fileInfo.DownloadCode)
if derr != nil {
fmt.Fprintf(os.Stderr, "[connect][media] 文件下载失败: %v\n", derr)
}
}
switch {
case localPath != "":
if prompt == "" {
prompt = "用户发来一个文件「" + fileName + "」(本地路径 " + localPath + "),请读取文件内容并回答。"
} else {
prompt = prompt + "\n(用户同时附了一个文件「" + fileName + "」,本地路径 " + localPath + ",请结合文件内容回答。)"
}
case fileInfo.DentryID != 0 && fileInfo.SpaceID != 0:
// API-sent file: resolve via storage API (userId→unionId,
// then getDownloadInfo). Falls back to metadata-only prompt
// if the download chain fails (e.g. missing permissions).
senderID := strings.TrimSpace(callbackData.SenderStaffId)
if unionID, uerr := mediaCli.getUserUnionID(context.Background(), senderID); uerr != nil {
fmt.Fprintf(os.Stderr, "[connect][media] userId→unionId 失败 (%s): %v\n", senderID, uerr)
} else if dp, derr := mediaCli.downloadDentryFile(context.Background(), fileInfo.SpaceID, fileInfo.DentryID, unionID, fileName); derr != nil {
fmt.Fprintf(os.Stderr, "[connect][media] 钉盘文件下载失败 spaceId=%d dentryId=%d: %v\n", fileInfo.SpaceID, fileInfo.DentryID, derr)
} else {
localPath = dp
}
if localPath != "" {
if prompt == "" {
prompt = "用户发来一个文件「" + fileName + "」(本地路径 " + localPath + "),请读取文件内容并回答。"
} else {
prompt = prompt + "\n(用户同时附了一个文件「" + fileName + "」,本地路径 " + localPath + ",请结合文件内容回答。)"
}
} else {
meta := fmt.Sprintf("文件名「%s」,dentryId=%d,spaceId=%d", fileName, fileInfo.DentryID, fileInfo.SpaceID)
if fileInfo.FileType != "" {
meta += ",类型=" + fileInfo.FileType
}
if fileInfo.FileSize > 0 {
meta += fmt.Sprintf(",大小=%d 字节", fileInfo.FileSize)
}
if prompt == "" {
prompt = "用户发来一个文件(" + meta + ")。文件下载失败,请基于文件名与用户随附的文字信息回答,必要时请用户改用客户端上传或补充文字描述。"
} else {
prompt = prompt + "\n(用户同时附了一个文件:" + meta + "。文件下载失败,请结合文件名与随附文字回答。)"
}
}
default:
if prompt == "" {
prompt = "(用户发来一个文件「" + fileName + "」,但文件下载失败了。请告知用户文件没收到,建议重新发送。)"
}
}
}
if extras.kb != nil { if extras.kb != nil {
prompt = extras.kb.augment(prompt) prompt = extras.kb.augment(prompt)
} }
@@ -1080,8 +1191,14 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
// ② agent runs; // ② agent runs;
// ③ on done: deliver the AI card with the final content; // ③ on done: deliver the AI card with the final content;
// ④ swap the chip to "🥳Done". // ④ swap the chip to "🥳Done".
// Reactions attach to the triggering message, but DingTalk rejects a
// reaction on an interactiveCard message (a bot @-mentioning this bot)
// with a 500 system.error — reactions are only supported on human
// messages. Skip the chip for those turns so we don't fire a call the
// platform always rejects; the reply itself is unaffected.
canReact := !strings.EqualFold(msgtype, "interactiveCard")
thinking := false thinking := false
if cardCli != nil { if cardCli != nil && canReact {
if terr := cardCli.markThinking(context.Background(), callbackData.ConversationId, msgID); terr != nil { if terr := cardCli.markThinking(context.Background(), callbackData.ConversationId, msgID); terr != nil {
fmt.Fprintf(os.Stderr, "[connect][card] Thinking 表态失败(不影响回复): %v\n", terr) fmt.Fprintf(os.Stderr, "[connect][card] Thinking 表态失败(不影响回复): %v\n", terr)
} else { } else {
@@ -1117,9 +1234,20 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
} }
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "[connect] 转发失败 (%s, 耗时 %s): %v\n", channel, time.Since(started).Round(time.Millisecond), err) fmt.Fprintf(os.Stderr, "[connect] 转发失败 (%s, 耗时 %s): %v\n", channel, time.Since(started).Round(time.Millisecond), err)
reply = fmt.Sprintf("(%s 调用失败:%v)", channel, err) if errors.Is(err, context.DeadlineExceeded) {
// Self-recovery: drop the conversation's session so the next
// message starts fresh instead of reusing a stuck one.
if r, ok := fwd.(sessionResetter); ok {
r.resetSession(convID)
}
reply = fmt.Sprintf("(%s 回复超时,已自动重置会话,请重试。如需调整超时上限可用 --agent-timeout)", channel)
} else {
reply = fmt.Sprintf("(%s 调用失败:%v)", channel, err)
}
health.onError(err)
} else { } else {
fmt.Fprintf(os.Stderr, "[connect] agent 已生成回复 (%s, 耗时 %s): %s\n", channel, time.Since(started).Round(time.Millisecond), truncateRunes(reply, 80)) fmt.Fprintf(os.Stderr, "[connect] agent 已生成回复 (%s, 耗时 %s): %s\n", channel, time.Since(started).Round(time.Millisecond), truncateRunes(reply, 80))
health.onReply()
} }
// Confirmation gate orchestration: if the agent's reply declared // Confirmation gate orchestration: if the agent's reply declared
@@ -1173,22 +1301,40 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
if !delivered { if !delivered {
// Fallback path: plain reply via the inbound sessionWebhook. // Fallback path: plain reply via the inbound sessionWebhook.
// Long replies go as markdown, short ones as text. // Retry up to 3 times with exponential backoff (1s, 2s, 4s)
// to handle transient network errors (EOF, timeout).
var sendErr error var sendErr error
if len([]rune(reply)) > 200 { backoffs := []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second}
sendErr = replier.SimpleReplyMarkdown(context.Background(), webhook, []byte(channel), []byte(reply)) for attempt := 0; attempt <= len(backoffs); attempt++ {
} else { if len([]rune(reply)) > 200 {
sendErr = replier.SimpleReplyText(context.Background(), webhook, []byte(reply)) sendErr = replier.SimpleReplyMarkdown(context.Background(), webhook, []byte(channel), []byte(reply))
} else {
sendErr = replier.SimpleReplyText(context.Background(), webhook, []byte(reply))
}
if sendErr == nil {
break
}
if attempt < len(backoffs) {
fmt.Fprintf(os.Stderr, "[connect] 普通消息发送失败 (%s, attempt %d/%d, msgId=%s): %v,%v 后重试\n",
channel, attempt+1, len(backoffs)+1, msgID, sendErr, backoffs[attempt])
time.Sleep(backoffs[attempt])
}
} }
if sendErr != nil { if sendErr != nil {
fmt.Fprintf(os.Stderr, "[connect] 普通消息发送失败 (%s, msgId=%s): %v\n", channel, msgID, sendErr) fmt.Fprintf(os.Stderr, "[connect] 普通消息发送失败(重试耗尽) (%s, msgId=%s): %v\n", channel, msgID, sendErr)
delivered = false
} else { } else {
fmt.Fprintf(os.Stderr, "[connect] 普通消息已发送 (%s, msgId=%s)\n", channel, msgID) fmt.Fprintf(os.Stderr, "[connect] 普通消息已发送 (%s, msgId=%s)\n", channel, msgID)
delivered = true
} }
} }
if thinking { if thinking {
cardCli.swapThinkingToDone(context.Background(), callbackData.ConversationId, msgID) if delivered {
cardCli.swapThinkingToDone(context.Background(), callbackData.ConversationId, msgID)
} else {
fmt.Fprintf(os.Stderr, "[connect] 回复发送失败,保留思考表态不切换完成 (%s, msgId=%s)\n", channel, msgID)
}
} }
}) })
return []byte(""), nil return []byte(""), nil
@@ -1208,6 +1354,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
return apperrors.NewInternal("stream 建连失败:" + err.Error()) return apperrors.NewInternal("stream 建连失败:" + err.Error())
} }
defer cli.Close() defer cli.Close()
health.onConnected()
<-ctx.Done() <-ctx.Done()
return nil return nil
} }
@@ -1231,6 +1378,16 @@ func envDurationMS(key string, def time.Duration) time.Duration {
return def return def
} }
// applyTimeout returns a context bounded by timeout when timeout > 0, or the
// original context unchanged when timeout == 0 (no limit). The returned cancel
// func is always safe to defer.
func applyTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
if timeout > 0 {
return context.WithTimeout(ctx, timeout)
}
return ctx, func() {}
}
// truncateRunes truncates by rune so multi-byte characters are never split. // truncateRunes truncates by rune so multi-byte characters are never split.
func truncateRunes(s string, max int) string { func truncateRunes(s string, max int) string {
r := []rune(s) r := []rune(s)
+23
View File
@@ -9,6 +9,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
) )
// TestBrandReply covers the qoderwork identity rewrite using the exact replies // TestBrandReply covers the qoderwork identity rewrite using the exact replies
@@ -122,3 +123,25 @@ func TestClaudeUserSettingsEnv(t *testing.T) {
} }
} }
} }
// TestCheckFDLimit verifies that checkFDLimit runs without panic and respects
// the envDurationMS pattern for the keepAlive default.
func TestCheckFDLimit(t *testing.T) {
// Should not panic regardless of the actual ulimit.
checkFDLimit()
}
func TestEnvDurationMS(t *testing.T) {
def := 30000 * time.Millisecond // 30s
if got := envDurationMS("DWS_CONNECT_KEEPALIVE_MS_TEST_ABSENT", def); got != def {
t.Fatalf("default keepAlive = %v, want %v", got, def)
}
t.Setenv("DWS_CONNECT_KEEPALIVE_MS_TEST", "10000")
if got := envDurationMS("DWS_CONNECT_KEEPALIVE_MS_TEST", def); got != 10*time.Second {
t.Fatalf("env override = %v, want 10s", got)
}
t.Setenv("DWS_CONNECT_KEEPALIVE_MS_TEST", "bogus")
if got := envDurationMS("DWS_CONNECT_KEEPALIVE_MS_TEST", def); got != def {
t.Fatalf("invalid env falls back to default = %v, want %v", got, def)
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ func (f *execForwarder) forwardStream(ctx context.Context, convID, text string,
if !f.canStream() || onDelta == nil { if !f.canStream() || onDelta == nil {
return f.forward(ctx, convID, text) return f.forward(ctx, convID, text)
} }
ctx, cancel := context.WithTimeout(ctx, f.timeout) ctx, cancel := applyTimeout(ctx, f.timeout)
defer cancel() defer cancel()
var args []string var args []string
+21 -6
View File
@@ -1611,7 +1611,7 @@ func normalizeDevAppRobotResult(content map[string]any) {
devAppMarkVersionPublishBlocked(content) devAppMarkVersionPublishBlocked(content)
} }
if localConnectReady { if localConnectReady {
steps = append(steps, devAppRobotConnectStep(clientID)) steps = append(steps, devAppRobotConnectStep(clientID, unifiedAppID))
} }
case "FAIL": case "FAIL":
lifecycle["phase"] = "failed" lifecycle["phase"] = "failed"
@@ -1725,14 +1725,29 @@ func devAppRobotRetryStep(taskID string, reuseTaskID bool) map[string]any {
}) })
} }
func devAppRobotConnectStep(clientID string) map[string]any { // devAppRobotConnectStep advertises the local-debug connect command. The
if clientID == "" { // preferred form is `--unified-app-id`, which reuses `dev app credentials get`
clientID = "<clientId>" // to fetch clientSecret at runtime — the secret never appears in argv, so it
// stays hidden from `ps` / journald / shell history. Only when unifiedAppID is
// unavailable do we fall back to `--robot-client-id`, and even then we point
// the caller at the safe path in doneWhen instead of hardcoding a
// clientSecret placeholder into the command string.
func devAppRobotConnectStep(clientID, unifiedAppID string) map[string]any {
var command, doneWhen string
if unifiedAppID != "" {
command = fmt.Sprintf("dws dev connect --unified-app-id %s --format json", unifiedAppID)
doneWhen = "本地 Stream 建联成功,进程保持运行;密钥由 credentials get 后台取回,命令行不出现 clientSecret"
} else {
if clientID == "" {
clientID = "<clientId>"
}
command = fmt.Sprintf("dws dev connect --robot-client-id %s --format json", clientID)
doneWhen = "本地 Stream 建联成功;建议改用 --unified-app-id <uappid>,避免 clientSecret 出现在命令行被 ps 看到"
} }
step := devAppNextStep(devAppStep{ step := devAppNextStep(devAppStep{
ID: "connect_local", ID: "connect_local",
Command: fmt.Sprintf("dws dev connect --robot-client-id %s --robot-client-secret <clientSecret-from-result> --format json", clientID), Command: command,
DoneWhen: "本地 Stream 建联成功,进程保持运行;clientSecret 只使用返回值,不写入命令建议", DoneWhen: doneWhen,
}) })
step["sensitiveFields"] = []string{"clientSecret"} step["sensitiveFields"] = []string{"clientSecret"}
step["optional"] = true step["optional"] = true
+22 -2
View File
@@ -519,6 +519,16 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
} }
} }
// Security: if the caller pasted clientSecret onto argv, warn once —
// any user on the box can lift it with `ps -ef`. The safe path is
// --unified-app-id, which resolves the secret through
// `dev app credentials get` at runtime so it never touches argv.
// Emit before dry-run so both preview and real launch surface the
// warning; the warning is idempotent (one line per invocation).
if strings.HasPrefix(resolvedBy, "flag:") {
fmt.Fprintln(cmd.ErrOrStderr(), "[connect] WARNING: --robot-client-secret 出现在命令行,任何本机用户都能通过 `ps` 看到;建议改用 --unified-app-id <uappid>,由 dev app credentials get 后台取密钥。")
}
if commandDryRun(cmd) { if commandDryRun(cmd) {
return writeCommandPayload(cmd, connectPreviewEnvelope(map[string]any{ return writeCommandPayload(cmd, connectPreviewEnvelope(map[string]any{
"channel": channel, "channel": channel,
@@ -535,7 +545,10 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
// connector alive 7x24. We resolve credentials/channel first (above) so // connector alive 7x24. We resolve credentials/channel first (above) so
// the parent fails fast on bad input before forking, then re-exec. // the parent fails fast on bad input before forking, then re-exec.
if daemonMode, _ := cmd.Flags().GetBool(daemonFlag); daemonMode { if daemonMode, _ := cmd.Flags().GetBool(daemonFlag); daemonMode {
return startDaemon(cmd, daemonDirKey(clientID, unifiedAppID), clientID) notifyStaffID := devAppStringFlag(cmd, "notify-staff-id")
profile, _ := cmd.Root().PersistentFlags().GetString("profile")
alwaysOn, _ := cmd.Flags().GetBool("alwayson")
return startDaemon(cmd, daemonDirKey(clientID, unifiedAppID), clientID, unifiedAppID, channel, notifyStaffID, strings.TrimSpace(profile), alwaysOn)
} }
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] channel=%s(%s)凭证来源=%s\n", channel, detectedBy, resolvedBy) fmt.Fprintf(cmd.ErrOrStderr(), "[connect] channel=%s(%s)凭证来源=%s\n", channel, detectedBy, resolvedBy)
@@ -546,7 +559,8 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
preferLegacyLeaf(cmd) preferLegacyLeaf(cmd)
// Daemon mode (see connect_daemon.go). --daemon detaches the connector into a // Daemon mode (see connect_daemon.go). --daemon detaches the connector into a
// self-restarting background supervisor; status/stop are sibling subcommands. // self-restarting background supervisor; status/stop are sibling subcommands.
cmd.Flags().Bool(daemonFlag, false, "守护进程模式:把连接器放到后台常驻(脱离终端、崩溃自拉起),父进程打印 pid/日志路径后退出(Windows 暂不支持)") cmd.Flags().Bool(daemonFlag, false, "守护进程模式:把连接器放到后台运行(脱离终端),父进程打印 pid/日志路径后退出(Windows 暂不支持)")
cmd.Flags().Bool("alwayson", false, "常驻模式:worker 崩溃后自动重启(仅 --daemon 生效)")
// Internal re-exec mode flags, hidden from help. // Internal re-exec mode flags, hidden from help.
cmd.Flags().Bool(daemonSuperviseFlag, false, "internal: run the daemon supervisor (set automatically by --daemon)") cmd.Flags().Bool(daemonSuperviseFlag, false, "internal: run the daemon supervisor (set automatically by --daemon)")
cmd.Flags().Bool(daemonWorkerFlag, false, "internal: run a single supervised connector worker (set automatically by the supervisor)") cmd.Flags().Bool(daemonWorkerFlag, false, "internal: run a single supervised connector worker (set automatically by the supervisor)")
@@ -555,6 +569,8 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
cmd.AddCommand( cmd.AddCommand(
newDevAppRobotConnectStatusCommand(), newDevAppRobotConnectStatusCommand(),
newDevAppRobotConnectStopCommand(), newDevAppRobotConnectStopCommand(),
newDevAppRobotConnectRestartCommand(),
newDevAppRobotConnectListCommand(runner),
) )
cmd.Flags().String("channel", "auto", "渠道:auto(默认,自动探测)|openclaw|qoder|qoderwork|hermes|workbuddy|claudecode|codebuddy|codex|gemini|opencode|custom(自研/未支持的 AI,配 --agent-cmd)") cmd.Flags().String("channel", "auto", "渠道:auto(默认,自动探测)|openclaw|qoder|qoderwork|hermes|workbuddy|claudecode|codebuddy|codex|gemini|opencode|custom(自研/未支持的 AI,配 --agent-cmd)")
cmd.Flags().String("agent-cmd", "", "自研/未支持的 AI 工具命令(无头/一次性:问题作为最后一个参数追加,答案打到 stdout);用来接入内置渠道之外的 AI(如网易有道龙虾 LobsterAI);等价于 --channel custom + 设 DWS_AGENT_CMD;env: DWS_AGENT_CMD") cmd.Flags().String("agent-cmd", "", "自研/未支持的 AI 工具命令(无头/一次性:问题作为最后一个参数追加,答案打到 stdout);用来接入内置渠道之外的 AI(如网易有道龙虾 LobsterAI);等价于 --channel custom + 设 DWS_AGENT_CMD;env: DWS_AGENT_CMD")
@@ -567,6 +583,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("agent-model", "", "覆盖本地 agent 模型(如 claude 的 sonnet/opus;默认用渠道内置模型,求快);env: DWS_AGENT_MODEL") cmd.Flags().String("agent-model", "", "覆盖本地 agent 模型(如 claude 的 sonnet/opus;默认用渠道内置模型,求快);env: DWS_AGENT_MODEL")
cmd.Flags().String("agent-workdir", "", "本地 agent 的运行目录(放知识文件可给机器人上下文;默认空白临时目录,求快);env: DWS_AGENT_WORKDIR") cmd.Flags().String("agent-workdir", "", "本地 agent 的运行目录(放知识文件可给机器人上下文;默认空白临时目录,求快);env: DWS_AGENT_WORKDIR")
cmd.Flags().Bool("agent-memory", true, "按会话续聊:同一群/单聊共享 agent 会话上下文(codex/opencode/qoder/qoderwork/claudecode/codebuddy/workbuddy 支持;--agent-memory=false 关闭)") cmd.Flags().Bool("agent-memory", true, "按会话续聊:同一群/单聊共享 agent 会话上下文(codex/opencode/qoder/qoderwork/claudecode/codebuddy/workbuddy 支持;--agent-memory=false 关闭)")
cmd.Flags().Int("agent-timeout", 0, "每次 agent 调用的超时时间(秒),0=不限制(默认);env: DWS_AGENT_TIMEOUT_MS(毫秒)")
cmd.Flags().Bool("reply-card", true, "用 AI 卡片回复(思考中→完成状态,同官方渠道体验);卡片失败自动回退普通消息;--reply-card=false 关闭") cmd.Flags().Bool("reply-card", true, "用 AI 卡片回复(思考中→完成状态,同官方渠道体验);卡片失败自动回退普通消息;--reply-card=false 关闭")
cmd.Flags().String("card-template", "", "AI 卡片模板 ID(开发者后台·本应用·AI 卡片设置里获取;模板按应用授权,强烈建议注册自己应用的模板);env: DWS_CARD_TEMPLATE") cmd.Flags().String("card-template", "", "AI 卡片模板 ID(开发者后台·本应用·AI 卡片设置里获取;模板按应用授权,强烈建议注册自己应用的模板);env: DWS_CARD_TEMPLATE")
cmd.Flags().String("knowledge-dir", "", "答疑知识目录(.md/.txt):每条消息本地检索 top-k 片段拼进 prompt,agent 仍在空目录跑、不拖慢回复;env: DWS_KNOWLEDGE_DIR") cmd.Flags().String("knowledge-dir", "", "答疑知识目录(.md/.txt):每条消息本地检索 top-k 片段拼进 prompt,agent 仍在空目录跑、不拖慢回复;env: DWS_KNOWLEDGE_DIR")
@@ -579,6 +596,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("role-config", "", "数字员工角色配置 YAML:用角色的主人/人设/知识源填充未显式给出的选项(显式 flag 优先);role 的 client_id 必须与本机器人一致;env: DWS_ROLE_CONFIG") cmd.Flags().String("role-config", "", "数字员工角色配置 YAML:用角色的主人/人设/知识源填充未显式给出的选项(显式 flag 优先);role 的 client_id 必须与本机器人一致;env: DWS_ROLE_CONFIG")
cmd.Flags().String("audit-sheet", "", "审计在线表格 ID/URL(axls):确认闸每个操作追加一行到该表格,可在钉钉随时查看;空=仅本地审计文件;env: DWS_AUDIT_SHEET") cmd.Flags().String("audit-sheet", "", "审计在线表格 ID/URL(axls):确认闸每个操作追加一行到该表格,可在钉钉随时查看;空=仅本地审计文件;env: DWS_AUDIT_SHEET")
cmd.Flags().String("audit-sheet-tab", "Sheet1", "审计表格的工作表 ID/名称(配合 --audit-sheet);env: DWS_AUDIT_SHEET_TAB") cmd.Flags().String("audit-sheet-tab", "Sheet1", "审计表格的工作表 ID/名称(配合 --audit-sheet);env: DWS_AUDIT_SHEET_TAB")
cmd.Flags().String("notify-staff-id", "", "状态通知 staffId:机器人启动/停止/崩溃时自动发钉钉消息通知此人;env: DWS_NOTIFY_STAFF_ID")
return cmd return cmd
} }
@@ -595,6 +613,7 @@ func connectAgentOptionsFromCommand(cmd *cobra.Command) connectAgentOptions {
workDir = strings.TrimSpace(os.Getenv("DWS_AGENT_WORKDIR")) workDir = strings.TrimSpace(os.Getenv("DWS_AGENT_WORKDIR"))
} }
memory, _ := cmd.Flags().GetBool("agent-memory") memory, _ := cmd.Flags().GetBool("agent-memory")
agentTimeoutSec, _ := cmd.Flags().GetInt("agent-timeout")
replyCard, _ := cmd.Flags().GetBool("reply-card") replyCard, _ := cmd.Flags().GetBool("reply-card")
// Env kill-switch for scripted/service runs: DWS_REPLY_CARD=0 disables // Env kill-switch for scripted/service runs: DWS_REPLY_CARD=0 disables
// cards regardless of the flag default. // cards regardless of the flag default.
@@ -658,6 +677,7 @@ func connectAgentOptionsFromCommand(cmd *cobra.Command) connectAgentOptions {
auditSheetTab = "Sheet1" auditSheetTab = "Sheet1"
} }
return connectAgentOptions{Model: model, WorkDir: workDir, Memory: memory, return connectAgentOptions{Model: model, WorkDir: workDir, Memory: memory,
Timeout: time.Duration(agentTimeoutSec) * time.Second,
ReplyCard: replyCard, CardTemplate: cardTemplate, ReplyCard: replyCard, CardTemplate: cardTemplate,
KnowledgeDir: knowledgeDir, KnowledgeDir: knowledgeDir,
KnowledgeSource: knowledgeSource, KnowledgeSource: knowledgeSource,
+24 -10
View File
@@ -39,10 +39,12 @@ func TestDevAppRobotConnectRegistered(t *testing.T) {
// dry-run path (which never launches the Stream connector). // dry-run path (which never launches the Stream connector).
func TestDevAppRobotConnectValidation(t *testing.T) { func TestDevAppRobotConnectValidation(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
args []string args []string
wantErr string wantErr string
wantJSON []string // substrings expected in successful dry-run output wantJSON []string // substrings expected in successful dry-run output
wantStderr []string // substrings expected in combined stderr/stdout stream
notStderr []string // substrings that must NOT appear
}{ }{
{ {
name: "no credentials and no unified-app-id", name: "no credentials and no unified-app-id",
@@ -55,14 +57,16 @@ func TestDevAppRobotConnectValidation(t *testing.T) {
wantErr: "未知渠道", wantErr: "未知渠道",
}, },
{ {
name: "explicit credentials dry-run emits plan", name: "explicit credentials dry-run emits plan and secret warning",
args: []string{"--channel", "claudecode", "--robot-client-id", "id1", "--robot-client-secret", "sec1", "--dry-run"}, args: []string{"--channel", "claudecode", "--robot-client-id", "id1", "--robot-client-secret", "sec1", "--dry-run"},
wantJSON: []string{"\"credentialSource\"", "flag:--robot-client-id/--robot-client-secret", "stream-bridge", "\"clientId\"", "\"completionState\": \"LOCAL_DEBUG_ONLY\"", "\"doesNotPublish\": true", "\"scope\": \"local_debug_only\""}, wantJSON: []string{"\"credentialSource\"", "flag:--robot-client-id/--robot-client-secret", "stream-bridge", "\"clientId\"", "\"completionState\": \"LOCAL_DEBUG_ONLY\"", "\"doesNotPublish\": true", "\"scope\": \"local_debug_only\""},
wantStderr: []string{"[connect] WARNING", "--robot-client-secret 出现在命令行", "建议改用 --unified-app-id"},
}, },
{ {
name: "unified-app-id dry-run skips credentials get", name: "unified-app-id dry-run skips credentials get and emits no secret warning",
args: []string{"--channel", "qoderwork", "--unified-app-id", "UAID", "--dry-run"}, args: []string{"--channel", "qoderwork", "--unified-app-id", "UAID", "--dry-run"},
wantJSON: []string{"credentials get, skipped in dry-run", "\"unifiedAppId\"", "\"completionState\": \"LOCAL_DEBUG_ONLY\"", "\"doesNotPublish\": true", "\"scope\": \"local_debug_only\""}, wantJSON: []string{"credentials get, skipped in dry-run", "\"unifiedAppId\"", "\"completionState\": \"LOCAL_DEBUG_ONLY\"", "\"doesNotPublish\": true", "\"scope\": \"local_debug_only\""},
notStderr: []string{"[connect] WARNING"},
}, },
} }
@@ -89,6 +93,16 @@ func TestDevAppRobotConnectValidation(t *testing.T) {
t.Fatalf("output missing %q:\n%s", sub, out.String()) t.Fatalf("output missing %q:\n%s", sub, out.String())
} }
} }
for _, sub := range tc.wantStderr {
if !strings.Contains(out.String(), sub) {
t.Fatalf("stderr/stdout missing %q:\n%s", sub, out.String())
}
}
for _, sub := range tc.notStderr {
if strings.Contains(out.String(), sub) {
t.Fatalf("stderr/stdout unexpectedly contains %q:\n%s", sub, out.String())
}
}
}) })
} }
} }
+5 -2
View File
@@ -1249,8 +1249,11 @@ func TestDevAppRobotResultSuccessAddsPublishAndConnectSteps(t *testing.T) {
if connect["blocking"] != false || connect["optional"] != true || connect["scope"] != "local_debug_only" { if connect["blocking"] != false || connect["optional"] != true || connect["scope"] != "local_debug_only" {
t.Fatalf("connect step = %#v, want optional local debug non-blocking step", connect) t.Fatalf("connect step = %#v, want optional local debug non-blocking step", connect)
} }
if command, _ := connect["command"].(string); !strings.Contains(command, "--robot-client-secret <clientSecret-from-result>") { if command, _ := connect["command"].(string); !strings.Contains(command, "--unified-app-id u-1") {
t.Fatalf("connect command = %q, want clientSecret placeholder", command) t.Fatalf("connect command = %q, want --unified-app-id form (safe: clientSecret not on argv)", command)
}
if command, _ := connect["command"].(string); strings.Contains(command, "--robot-client-secret") {
t.Fatalf("connect command = %q, must not put clientSecret on argv", command)
} }
assertDevAppStepCommandsDoNotContain(t, steps, "secret-client") assertDevAppStepCommandsDoNotContain(t, steps, "secret-client")
} }
+3
View File
@@ -85,6 +85,9 @@ type Hooks struct {
// --- HTTP headers --- // --- HTTP headers ---
MergeHeaders func(base map[string]string) map[string]string MergeHeaders func(base map[string]string) map[string]string
// --- EnterpriseCredential HTTP headers ---
EnterpriseCredentialHeaders func(base map[string]string) map[string]string
// --- auth --- // --- auth ---
AuthClientID string // OAuth client ID for device-flow authorisation AuthClientID string // OAuth client ID for device-flow authorisation
AuthClientFromMCP bool // true → fetch client ID from MCP at runtime AuthClientFromMCP bool // true → fetch client ID from MCP at runtime
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env sh
# connect-watchdog.sh — keep a `dws dev connect` connector alive using the
# health contract from `dws dev connect status --json`.
#
# This is the "脚本" side of the 0701 review: a small, inspectable local watchdog
# that (1) asks dws whether the connection is actually healthy — not just whether
# a process exists — and (2) relaunches it when it is down/degraded. Drop it into
# cron or launchd; it is idempotent, so running it every few minutes is safe.
#
# It consumes the machine-readable contract, so it never parses `ps` or guesses.
#
# Usage:
# connect-watchdog.sh --client-id <clientId> [--dry-run] -- <launch command...>
#
# Example (relaunch a daemon connector if it is not healthy):
# connect-watchdog.sh --client-id ding123 -- \
# dws dev connect --robot-client-id ding123 --channel opencode --daemon
#
# cron (every 5 minutes):
# */5 * * * * /path/to/connect-watchdog.sh --client-id ding123 -- \
# dws dev connect --robot-client-id ding123 --channel opencode --daemon >> ~/.dws/connect/ding123/watchdog.log 2>&1
#
# Exit codes: 0 = healthy (no action) or relaunch issued; 1 = usage error.
set -eu
DWS="${DWS_BIN:-dws}"
CLIENT_ID=""
DRY_RUN=0
while [ $# -gt 0 ]; do
case "$1" in
--client-id) CLIENT_ID="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
--) shift; break ;;
*) echo "unknown arg: $1" >&2; exit 1 ;;
esac
done
if [ -z "$CLIENT_ID" ]; then
echo "usage: connect-watchdog.sh --client-id <clientId> [--dry-run] -- <launch command...>" >&2
exit 1
fi
if [ $# -eq 0 ]; then
echo "error: missing launch command after --" >&2
exit 1
fi
ts() { date '+%Y-%m-%d %H:%M:%S'; }
# Ask dws for the health verdict. `--json` is the stable contract.
STATUS_JSON="$("$DWS" dev connect status --robot-client-id "$CLIENT_ID" --json 2>/dev/null || true)"
# Extract "state" without a hard jq dependency (fall back to jq when present).
if command -v jq >/dev/null 2>&1; then
STATE="$(printf '%s' "$STATUS_JSON" | jq -r '.state // empty' 2>/dev/null)"
else
STATE="$(printf '%s' "$STATUS_JSON" | sed -n 's/.*"state"[[:space:]]*:[[:space:]]*"\([a-z_]*\)".*/\1/p' | head -n1)"
fi
# Treat empty, "null", or any non-word value as unknown so the watchdog relaunches.
case "$STATE" in ""|null) STATE="unknown" ;; esac
case "$STATE" in
healthy)
echo "$(ts) [watchdog] $CLIENT_ID healthy — no action"
exit 0
;;
not_running|down|degraded|unknown)
echo "$(ts) [watchdog] $CLIENT_ID state=$STATE — relaunching: $*"
if [ "$DRY_RUN" -eq 1 ]; then
echo "$(ts) [watchdog] dry-run, not executing"
exit 0
fi
# For down/degraded, stop the old connector first so we do not fight the
# single-instance lock; not_running has nothing to stop (ignore failures).
if [ "$STATE" = "down" ] || [ "$STATE" = "degraded" ]; then
"$DWS" dev connect stop --robot-client-id "$CLIENT_ID" >/dev/null 2>&1 || true
fi
exec "$@"
;;
*)
echo "$(ts) [watchdog] $CLIENT_ID unexpected state=$STATE — no action" >&2
exit 0
;;
esac
+80
View File
@@ -0,0 +1,80 @@
# connect supervision recipes
Keep a `dws dev connect` connector alive across crashes, logout, and reboots —
**without** marrying one process manager. Every recipe here consumes the same
health contract, `dws dev connect status --json` (see PR #543), so they restart
on a *real* health verdict instead of guessing from the process table.
Two layers, pick per host:
| Host | Recipe | Boot persistence | Extra dependency |
|------|--------|------------------|------------------|
| anywhere (default) | `dws dev connect --daemon` | no | none (built-in) |
| macOS | `launchd.dws-connect.plist` | yes | none (OS built-in) |
| Linux | `systemd.dws-connect.service` | yes | none (OS built-in) |
| Windows | NSSM (see below) | yes | NSSM |
| Node teams | `pm2.ecosystem.config.js` | yes | Node + pm2 |
All of them run `connect-watchdog.sh`, which is the "脚本" from the 0701 review:
poll `status --json`, and relaunch when `state` is `down`/`degraded`/`not_running`.
## Why a watchdog on top of a supervisor?
`--daemon`, launchd, systemd, and pm2 all restart on **process death**. The
failure mode the review flagged — a connection that is *alive but deaf* — never
kills the process, so none of them see it. The watchdog closes that by asking
dws for the health verdict, not the OS for the pid.
## The watchdog
```sh
connect-watchdog.sh --client-id <clientId> [--dry-run] -- <launch command...>
```
- `--client-id` — the robot clientId (how the connector is keyed on disk).
- everything after `--` — the command to run when unhealthy.
- reads `dws dev connect status --robot-client-id <id> --json`, relaunches only
when needed; on `down`/`degraded` it stops the old connector first to avoid the
single-instance lock. Idempotent, safe to run every few minutes.
## macOS (launchd)
Edit `launchd.dws-connect.plist` (clientId, channel, absolute paths), then:
```sh
cp launchd.dws-connect.plist ~/Library/LaunchAgents/com.dingtalk.dws.connect.plist
launchctl load ~/Library/LaunchAgents/com.dingtalk.dws.connect.plist
```
## Linux (systemd --user)
Edit `systemd.dws-connect.service`, then:
```sh
mkdir -p ~/.config/systemd/user
cp systemd.dws-connect.service ~/.config/systemd/user/dws-connect.service
systemctl --user daemon-reload
systemctl --user enable --now dws-connect.service
loginctl enable-linger "$USER" # keep running after logout
```
## Windows (NSSM)
`--daemon` is not supported on Windows; run the foreground connector as a
service. Install NSSM, then:
```
nssm install dws-connect "C:\path\to\dws.exe" dev connect --robot-client-id <id> --channel opencode
nssm set dws-connect AppExit Default Restart
nssm start dws-connect
```
## Node teams (pm2)
Only if you already run pm2 — it is **not** a dependency of dws. It supervises
the foreground connector (not `--daemon`, to avoid double supervision):
```sh
pm2 start pm2.ecosystem.config.js
pm2 save && pm2 startup # boot persistence
```
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- macOS launchd agent: keep a dws connector alive + watchdog every 5 min.
Edit: <clientId>, --channel, and the absolute paths, then load with
launchctl (see recipes/README.md). Consumes `dws dev connect status --json`. -->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.dingtalk.dws.connect</string>
<key>ProgramArguments</key>
<array>
<string>/absolute/path/to/scripts/dev/connect-watchdog.sh</string>
<string>--client-id</string>
<string>REPLACE_CLIENT_ID</string>
<string>--</string>
<string>/usr/local/bin/dws</string>
<string>dev</string>
<string>connect</string>
<string>--robot-client-id</string>
<string>REPLACE_CLIENT_ID</string>
<string>--channel</string>
<string>opencode</string>
<string>--daemon</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>DWS_BIN</key>
<string>/usr/local/bin/dws</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>StartInterval</key>
<integer>300</integer>
<key>StandardOutPath</key>
<string>REPLACE_HOME/.dws/connect/REPLACE_CLIENT_ID/watchdog.log</string>
<key>StandardErrorPath</key>
<string>REPLACE_HOME/.dws/connect/REPLACE_CLIENT_ID/watchdog.log</string>
</dict>
</plist>
@@ -0,0 +1,23 @@
// pm2 ecosystem for teams already running pm2. pm2 is NOT a dependency of dws —
// this is one optional host, not the default. It supervises the FOREGROUND
// connector (not --daemon) to avoid double supervision. pm2 restarts on process
// death; pair with connect-watchdog (a pm2 cron_restart or an OS cron) for the
// alive-but-deaf case, which only `dws dev connect status --json` can detect.
//
// pm2 start pm2.ecosystem.config.js
// pm2 save && pm2 startup // boot persistence
module.exports = {
apps: [
{
name: 'dws-connect',
script: 'dws',
args: 'dev connect --robot-client-id REPLACE_CLIENT_ID --channel opencode',
autorestart: true,
restart_delay: 5000,
max_restarts: 50,
// Optional: periodic health-driven restart. A cleaner setup runs
// connect-watchdog.sh from cron so restarts key off `status --json`.
// cron_restart: '*/30 * * * *',
},
],
}
@@ -0,0 +1,19 @@
# Linux systemd --user unit: keep a dws connector alive. systemd restarts on
# process death; connect-watchdog (run via a companion timer, or fold the poll
# into ExecStart) catches the alive-but-deaf case via `status --json`.
# Edit ExecStart (clientId, channel, absolute dws path), install per README.
[Unit]
Description=dws dev connect (DingTalk robot connector)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Environment=DWS_BIN=/usr/local/bin/dws
# Foreground connector (NOT --daemon: systemd is the supervisor here).
ExecStart=/usr/local/bin/dws dev connect --robot-client-id REPLACE_CLIENT_ID --channel opencode
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
+1 -1
View File
@@ -316,7 +316,7 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
```bash ```bash
# 第一步:创建任务(按 scope 传必要参数) # 第一步:创建任务(按 scope 传必要参数)
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> --format excel --timeout-ms 1000 dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> --export-format excel --timeout-ms 1000
# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl # 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 3000 dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 3000
+1
View File
@@ -449,6 +449,7 @@ Flags:
- 纯文本/Markdown 单聊发送时 `--user` 和 `--open-dingtalk-id` 都可用;传 `--user` 时直接走 userId 发送能力 - 纯文本/Markdown 单聊发送时 `--user` 和 `--open-dingtalk-id` 都可用;传 `--user` 时直接走 userId 发送能力
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group) - --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- --at-all 和 --at-open-dingtalk-ids 仅在 --group 群聊时生效,单聊时无效;当设置--at-all时,消息内容中一定要包含对应的占位符@all;当设置--at-open-dingtalk-ids openDingTalkId1,openDingTalkId2时,消息内容中一定要包含对应格式的占位符<@openDingTalkId1> <@openDingTalkId2> - --at-all 和 --at-open-dingtalk-ids 仅在 --group 群聊时生效,单聊时无效;当设置--at-all时,消息内容中一定要包含对应的占位符@all;当设置--at-open-dingtalk-ids openDingTalkId1,openDingTalkId2时,消息内容中一定要包含对应格式的占位符<@openDingTalkId1> <@openDingTalkId2>
- **@ 群内机器人**:务必使用 `dws chat group bots --group <openConversationId>` 返回的 openDingtalkId(群级别 ID,与全局搜索结果不同);全局 `chat bot find` 返回的 ID 无法正确 @ 机器人
- **换行符**:消息内容按 Markdown 渲染,换行有两层要求,缺一不可: - **换行符**:消息内容按 Markdown 渲染,换行有两层要求,缺一不可:
1. 必须使用**真实换行符**(Unicode `U+000A`),而非字面量字符串 `\n`(反斜杠 + 字母 n)。程序或大模型构造参数时,须确保已正确反转义;否则全部内容会渲染在同一行 1. 必须使用**真实换行符**(Unicode `U+000A`),而非字面量字符串 `\n`(反斜杠 + 字母 n)。程序或大模型构造参数时,须确保已正确反转义;否则全部内容会渲染在同一行
2. Markdown 规范下**单个换行不产生换行效果**。需要换行时请使用:段落分隔(连续两个真实换行符 `\n\n`)、行尾两个空格 + 真实换行符(硬换行 `<br>`),或直接写 HTML 的 `<br>` 标签 2. Markdown 规范下**单个换行不产生换行效果**。需要换行时请使用:段落分隔(连续两个真实换行符 `\n\n`)、行尾两个空格 + 真实换行符(硬换行 `<br>`),或直接写 HTML 的 `<br>` 标签
@@ -316,7 +316,7 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
```bash ```bash
# 第一步:创建任务(按 scope 传必要参数) # 第一步:创建任务(按 scope 传必要参数)
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> --format excel --timeout-ms 1000 dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> --export-format excel --timeout-ms 1000
# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl # 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 3000 dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 3000
@@ -444,6 +444,7 @@ Flags:
- 纯文本/Markdown 单聊发送时 `--user` 和 `--open-dingtalk-id` 都可用;传 `--user` 时直接走 userId 发送能力 - 纯文本/Markdown 单聊发送时 `--user` 和 `--open-dingtalk-id` 都可用;传 `--user` 时直接走 userId 发送能力
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group) - --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- --at-all 和 --at-open-dingtalk-ids 仅在 --group 群聊时生效,单聊时无效;当设置--at-all时,消息内容中一定要包含对应的占位符@all;当设置--at-open-dingtalk-ids openDingTalkId1,openDingTalkId2时,消息内容中一定要包含对应格式的占位符<@openDingTalkId1> <@openDingTalkId2> - --at-all 和 --at-open-dingtalk-ids 仅在 --group 群聊时生效,单聊时无效;当设置--at-all时,消息内容中一定要包含对应的占位符@all;当设置--at-open-dingtalk-ids openDingTalkId1,openDingTalkId2时,消息内容中一定要包含对应格式的占位符<@openDingTalkId1> <@openDingTalkId2>
- **@ 群内机器人**:务必使用 `dws chat group bots --group <openConversationId>` 返回的 openDingtalkId(群级别 ID,与全局搜索结果不同);全局 `chat bot find` 返回的 ID 无法正确 @ 机器人
- **换行符**:消息内容按 Markdown 渲染,换行有两层要求,缺一不可: - **换行符**:消息内容按 Markdown 渲染,换行有两层要求,缺一不可:
1. 必须使用**真实换行符**(Unicode `U+000A`),而非字面量字符串 `\n`(反斜杠 + 字母 n)。程序或大模型构造参数时,须确保已正确反转义;否则全部内容会渲染在同一行 1. 必须使用**真实换行符**(Unicode `U+000A`),而非字面量字符串 `\n`(反斜杠 + 字母 n)。程序或大模型构造参数时,须确保已正确反转义;否则全部内容会渲染在同一行
2. Markdown 规范下**单个换行不产生换行效果**。需要换行时请使用:段落分隔(连续两个真实换行符 `\n\n`)、行尾两个空格 + 真实换行符(硬换行 `<br>`),或直接写 HTML 的 `<br>` 标签 2. Markdown 规范下**单个换行不产生换行效果**。需要换行时请使用:段落分隔(连续两个真实换行符 `\n\n`)、行尾两个空格 + 真实换行符(硬换行 `<br>`),或直接写 HTML 的 `<br>` 标签
@@ -532,6 +533,7 @@ Flags:
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group) - --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- --at-user-ids 仅在 --group 群聊时生效,单聊时无效;设置时 --text 中需包含 @userId 对应文本 - --at-user-ids 仅在 --group 群聊时生效,单聊时无效;设置时 --text 中需包含 @userId 对应文本
- --at-open-dingtalk-ids 仅在 --group 群聊时生效,单聊时无效;设置时 --text 中需包含 @openDingtalkId 对应文本 - --at-open-dingtalk-ids 仅在 --group 群聊时生效,单聊时无效;设置时 --text 中需包含 @openDingtalkId 对应文本
- **@ 群内机器人**:务必使用 `dws chat group bots --group <openConversationId>` 返回的 openDingtalkId(群级别 ID,与全局搜索结果不同);全局 `chat bot find` 返回的 ID 无法正确 @ 机器人
- --at-all @所有人,仅群聊时生效;只需带上 --at-all flag 即可,服务端会自动处理 - --at-all @所有人,仅群聊时生效;只需带上 --at-all flag 即可,服务端会自动处理
- userId 获取方式:`dws contact user search --query "姓名"` 搜人获取 userId - userId 获取方式:`dws contact user search --query "姓名"` 搜人获取 userId
- **换行符**:--text 按 Markdown 渲染,换行规则同 `chat message send`: - **换行符**:--text 按 Markdown 渲染,换行规则同 `chat message send`:
@@ -27,6 +27,17 @@ dev 的端到端任务都是「定位应用,改容器某节点,按审批需
5. 若走无绑定的 `robot submit/result`,只有结果返回明确 `unifiedAppId` 才能继续版本发布 5. 若走无绑定的 `robot submit/result`,只有结果返回明确 `unifiedAppId` 才能继续版本发布
6. 完成态与缺 `unifiedAppId`、`SELECT_APPROVER` 等门禁判定见 [SKILL.md](../SKILL.md)「核心规则」:建联成功 + 版本进入 `RELEASE`/`AUDIT`/`UNDER_REVIEW` 才算完成 6. 完成态与缺 `unifiedAppId`、`SELECT_APPROVER` 等门禁判定见 [SKILL.md](../SKILL.md)「核心规则」:建联成功 + 版本进入 `RELEASE`/`AUDIT`/`UNDER_REVIEW` 才算完成
## 重启守护进程连接器(不存密钥)
守护进程被 stop / kill / 崩溃后,通过持久化的 `unifiedAppId` 重新拉取密钥并重启,无需本地保存 AppSecret。
1. `dev connect --daemon --unified-app-id <id> --channel <channel>` 首次启动(`unifiedAppId` 和 `channel` 会写入 `~/.dws/connect/<key>/daemon.pid`)
2. `dev connect restart --unified-app-id <id>` 重启:自动 stop 旧进程 → 从 dev 平台拉取 AppKey/Secret → 重新建联
3. `dev connect status --unified-app-id <id>` 确认恢复 `healthy`
4. 若 daemon.pid 未持久化 `unifiedAppId`(如用 `--robot-client-id` 直接启动的),restart 会提示改用 `--unified-app-id` 启动
注意:密钥不落盘,每次 restart 动态从开发者平台获取;`daemon.pid` 只存 `unifiedAppId`、`channel`、`clientId`(公开值)。
## 上传图片拿 mediaId(应用图标 / 机器人图标) ## 上传图片拿 mediaId(应用图标 / 机器人图标)
应用图标、机器人图标都靠 mediaId 指定,但 dev 命令集不含上传——mediaId 要调钉钉 OpenAPI 拿到: 应用图标、机器人图标都靠 mediaId 指定,但 dev 命令集不含上传——mediaId 要调钉钉 OpenAPI 拿到: