Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5dbefb27e | ||
|
|
1c1abf3069 | ||
|
|
043881e0e3 | ||
|
|
b63e1b4469 |
@@ -42,3 +42,5 @@ dws.zip
|
|||||||
# 功能测试运行产物
|
# 功能测试运行产物
|
||||||
results.jsonl
|
results.jsonl
|
||||||
test/dev_functional/results.jsonl
|
test/dev_functional/results.jsonl
|
||||||
|
.vercel
|
||||||
|
.env*
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,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
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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,这是啥意思?」
|
||||||
|
|||||||
@@ -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命令 一次性问答参数 "用户的问题"`(问题作为最后一个参数追加),把它打印出来的内容当作回复发回群里。
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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 重新登录。"))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(), "")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = "" })
|
||||||
|
|||||||
@@ -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() {}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Executable
+85
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 拿到:
|
||||||
|
|||||||
Reference in New Issue
Block a user