diff --git a/.gitignore b/.gitignore index ae035cd2..8c98ebf1 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,5 @@ dws.zip # 功能测试运行产物 results.jsonl test/dev_functional/results.jsonl +.vercel +.env* diff --git a/CHANGELOG.md b/CHANGELOG.md index 8501c6ed..7aab1e93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,36 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [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 ### Fixed diff --git a/README.md b/README.md index bc86af77..1d40d0ca 100644 --- a/README.md +++ b/README.md @@ -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. ```bash -dws dev connect --channel auto --robot-client-id --robot-client-secret +dws dev connect --channel auto --unified-app-id ``` +> `--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 --robot-client-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): | Command | Effect | diff --git a/docs/connect-daemon-service.md b/docs/connect-daemon-service.md index e7e491b3..eb4bf0c0 100644 --- a/docs/connect-daemon-service.md +++ b/docs/connect-daemon-service.md @@ -4,17 +4,25 @@ 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. +> **Security**: prefer `--unified-app-id ` 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) ```bash # Detach into a background supervisor that restarts the connector if it crashes. dws devapp robot connect --daemon \ --channel claudecode \ - --robot-client-id --robot-client-secret + --unified-app-id -# Inspect / stop it. -dws devapp robot connect status --robot-client-id -dws devapp robot connect stop --robot-client-id +# Inspect / stop / restart it (locate the daemon by unifiedAppId). +dws devapp robot connect status --unified-app-id +dws devapp robot connect stop --unified-app-id +dws devapp robot connect restart --unified-app-id ``` - The parent prints the daemon pid and the log path, then exits. @@ -23,9 +31,9 @@ dws devapp robot connect stop --robot-client-id 10 consecutive fast failures) when it exits abnormally. - The single-instance lock (one connector per robot per machine) is reused, so a duplicate daemon refuses to start. -- Logs go to `~/.dws/connect//daemon.log` with size-based rotation +- Logs go to `~/.dws/connect//daemon.log` with size-based rotation (5 MB x 2 backups), and the pid file lives at - `~/.dws/connect//daemon.pid`. + `~/.dws/connect//daemon.pid`. - The daemon does NOT survive a reboot. For that, use Option B. > 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 Save as `~/Library/LaunchAgents/com.dingtalk.dws.connect.plist`, edit the paths -and credentials, then `launchctl load -w `. +and `REPLACE_UNIFIED_APP_ID`, then `launchctl load -w `. ```xml @@ -57,10 +65,8 @@ and credentials, then `launchctl load -w `. connect --channel claudecode - --robot-client-id - REPLACE_CLIENT_ID - --robot-client-secret - REPLACE_CLIENT_SECRET + --unified-app-id + REPLACE_UNIFIED_APP_ID RunAtLoad @@ -86,8 +92,8 @@ itself relies on the single-instance lock to avoid duplicates. ### Linux — systemd (user service) -Save as `~/.config/systemd/user/dws-connect.service`, edit paths/credentials, -then: +Save as `~/.config/systemd/user/dws-connect.service`, edit paths and +`REPLACE_UNIFIED_APP_ID`, then: ```bash systemctl --user daemon-reload @@ -106,8 +112,7 @@ Wants=network-online.target Type=simple ExecStart=/usr/local/bin/dws devapp robot connect \ --channel claudecode \ - --robot-client-id REPLACE_CLIENT_ID \ - --robot-client-secret REPLACE_CLIENT_SECRET + --unified-app-id REPLACE_UNIFIED_APP_ID Restart=always RestartSec=5 # Optional hardening: @@ -121,6 +126,22 @@ WantedBy=default.target `Restart=always` + `RestartSec` gives crash recovery; systemd captures stdout/ 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 --robot-client-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 devapp robot 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 - Just need it to outlive the terminal and self-heal on crash → `--daemon`. diff --git a/docs/dev-agent-test-cases.md b/docs/dev-agent-test-cases.md index e41112f2..0cad03e4 100644 --- a/docs/dev-agent-test-cases.md +++ b/docs/dev-agent-test-cases.md @@ -108,9 +108,9 @@ - **期望(分步)**: 1. 新建:`robot submit --name <应用名> --robot-name 小助手 --desc <功能> --dry-run` → `--yes`(拿 taskId)→ 按 `intervalSeconds` 轮询 `robot result --task-id `,只有 `SUCCESS` 才用返回 `robotCode/clientId/clientSecret`(敏感)。 2. 现有应用:`robot get` 若 `robotStatus=UNCONFIGURED` → `robot config --unified-app-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 是前台长驻进程,对话里跑要后台运行并告诉用户怎么停,或引导自己开终端。 -- **通过判据**:走异步 submit/result(同步建号已下线),轮询到 SUCCESS 再用凭证;未配置时走 config 不是 enable;config 是 upsert;写后回读 `robotStatus`;建联先 dry-run 预检、处理好长驻/缺凭证(先 submit/result 建号)。 -- **易错点**:找「同步一次建好」的命令;WAITING 就用凭证;robot info not exist 时去 enable;前台直接起 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 建号);默认用 `--unified-app-id` 建联而不是把 clientSecret 明文拼进命令行(避免被 `ps` 拉到)。 +- **易错点**:找「同步一次建好」的命令;WAITING 就用凭证;robot info not exist 时去 enable;前台直接起 connect 卡住对话;把 clientSecret 直接怼到命令行上。 ### C9. 事件订阅与上游错误排查 - **用户说**:「让这个应用订阅『群成员入群』事件,订阅完看下当前订阅了哪些,再把它取消掉;对了我之前发版本报了个 errcode 62012,这是啥意思?」 diff --git a/docs/robot-quickstart.md b/docs/robot-quickstart.md index 3b135502..4a92c997 100644 --- a/docs/robot-quickstart.md +++ b/docs/robot-quickstart.md @@ -55,18 +55,21 @@ dws dev app robot submit --name 我的智能体 --robot-name 小助手 --desc " dws dev app robot result --task-id 上一步返回的taskId --format json ``` -`status` 变成 `SUCCESS` 后,返回结果里的 `clientId` 和 `clientSecret` **保存好**,下一步要用。 +`status` 变成 `SUCCESS` 后,返回结果里的 `unifiedAppId` **记下来**,下一步要用。(`clientId` / `clientSecret` 也会返回,但下一步默认走 `unifiedAppId`,密钥由 dws 后台从 `credentials get` 自动拉取,你不需要手工复制密钥。) ## 第三步:把机器人接上你本地的 AI ```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 等) - 这个命令是前台运行的:窗口开着机器人在线,关掉窗口机器人下线 +> 安全提示:老写法 `--robot-client-id --robot-client-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 dws dev connect \ --agent-cmd "你的AI命令 一次性问答参数" \ - --robot-client-id dingxxxx --robot-client-secret yyyy + --unified-app-id 你的unifiedAppId ``` 机器人收到群消息后,会执行 `你的AI命令 一次性问答参数 "用户的问题"`(问题作为最后一个参数追加),把它打印出来的内容当作回复发回群里。 diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 7accbc87..bf5c05ab 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -17,6 +17,7 @@ import ( "bytes" "context" "encoding/json" + "regexp" "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" @@ -121,7 +122,7 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command { newChatBotSearchCommand(runner), ) - root.AddCommand(message, group, bot, newChatFileGroup(runner)) + root.AddCommand(message, group, bot, newChatFileGroup(runner), newChatMediaGroup()) return root } @@ -324,7 +325,7 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command { cmd.Flags().String("text", "", "消息内容,支持 Markdown (也可作位置参数)") cmd.Flags().String("title", "", "消息标题 (可选,未指定时从内容截取)") 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 ` 返回的 openDingtalkId(群级别 ID,与全局搜索结果不同)") cmd.Flags().String("uuid", "", "幂等 UUID (可选,24h 内相同 uuid 不重复发送)") cmd.Flags().String("msg-type", "", "富媒体类型: image / file (纯文本/Markdown 留空)") cmd.Flags().String("media-id", "", "图片 mediaId (msg-type=image 时必填)") @@ -530,7 +531,7 @@ func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command { Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { - params, tool, err := buildChatMessageSendByBotInvocation(cmd) + params, tool, err := buildChatMessageSendByBotInvocation(cmd, runner) if err != nil { return err } @@ -556,6 +557,9 @@ func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command { cmd.Flags().String("text", "", "消息内容 Markdown (必填)") cmd.Flags().String("title", "", "消息标题 (必填)") 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 ` 返回的 openDingtalkId(群级别 ID,与全局搜索结果不同)") return cmd } @@ -642,7 +646,7 @@ func newChatGroupCreateCommand(runner executor.Runner) *cobra.Command { 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() group, err := cmd.Flags().GetString("group") @@ -690,6 +694,33 @@ func buildChatMessageSendByBotInvocation(cmd *cobra.Command) (map[string]any, st } if strings.TrimSpace(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 } @@ -723,6 +754,52 @@ func splitCSVStrings(raw string) []string { 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 { out := make([]any, 0, len(values)) for _, value := range values { diff --git a/internal/helpers/chat_media_upload.go b/internal/helpers/chat_media_upload.go new file mode 100644 index 00000000..74569696 --- /dev/null +++ b/internal/helpers/chat_media_upload.go @@ -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 --client-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 +} diff --git a/internal/helpers/chat_test.go b/internal/helpers/chat_test.go index f8ae6059..7705368d 100644 --- a/internal/helpers/chat_test.go +++ b/internal/helpers/chat_test.go @@ -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 { switch av := a.(type) { 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) + } + }) + } +} diff --git a/internal/helpers/connect_card.go b/internal/helpers/connect_card.go index c0726a87..f1097aff 100644 --- a/internal/helpers/connect_card.go +++ b/internal/helpers/connect_card.go @@ -168,15 +168,21 @@ func (c *aiCardClient) callRaw(ctx context.Context, method, path string, payload if err != nil { 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 { return "", err } - req, err := http.NewRequestWithContext(ctx, method, dingtalkCardAPIBase+path, bytes.NewReader(body)) - if err != nil { - return "", err + if payload != nil { + req.Header.Set("Content-Type", "application/json") } - req.Header.Set("Content-Type", "application/json") req.Header.Set("x-acs-dingtalk-access-token", token) resp, err := c.httpClient.Do(req) if err != nil { diff --git a/internal/helpers/connect_codex_appserver.go b/internal/helpers/connect_codex_appserver.go index 893e89d0..1be1bdb3 100644 --- a/internal/helpers/connect_codex_appserver.go +++ b/internal/helpers/connect_codex_appserver.go @@ -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) { - ctx, cancel := context.WithTimeout(ctx, f.timeout) + ctx, cancel := applyTimeout(ctx, f.timeout) defer cancel() var state *codexThreadState diff --git a/internal/helpers/connect_daemon.go b/internal/helpers/connect_daemon.go index 767a89a5..6a1cc7ed 100644 --- a/internal/helpers/connect_daemon.go +++ b/internal/helpers/connect_daemon.go @@ -28,7 +28,10 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "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/internal/cobracmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "github.com/spf13/cobra" ) @@ -58,11 +61,21 @@ const ( // 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). type daemonState struct { - Pid int `json:"pid"` - StartUnix int64 `json:"startUnix"` - LogPath string `json:"logPath"` - DirKey string `json:"dirKey"` - ClientID string `json:"clientId,omitempty"` + Pid int `json:"pid"` + StartUnix int64 `json:"startUnix"` + LogPath string `json:"logPath"` + DirKey string `json:"dirKey"` + 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 @@ -97,38 +110,42 @@ func connectDaemonDir(dirKey string) (string, error) { return dir, nil } -func daemonPidPath(dir string) string { return filepath.Join(dir, "daemon.pid") } -func daemonLogPath(dir string) string { return filepath.Join(dir, "daemon.log") } +func daemonPidPath(dir string) string { return filepath.Join(dir, "daemon.pid") } +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) -// so a reader never sees a half-written file. +// writeDaemonState atomically persists the daemon state to daemon-state.json +// (persistent, survives supervisor exit) so restart/list can recover config. func writeDaemonState(dir string, st daemonState) error { data, err := json.MarshalIndent(st, "", " ") if err != nil { return err } - tmp := daemonPidPath(dir) + ".tmp" + tmp := daemonStatePath(dir) + ".tmp" if err := os.WriteFile(tmp, data, config.FilePerm); err != nil { 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 -// callers can treat "not running" distinctly from a real I/O error. +// readDaemonState loads the daemon state. Reads daemon-state.json (persistent) +// first, falls back to daemon.pid for backward compat with old connectors. func readDaemonState(dir string) (*daemonState, error) { - data, err := os.ReadFile(daemonPidPath(dir)) - if err != nil { - if os.IsNotExist(err) { - return nil, nil + for _, p := range []string{daemonStatePath(dir), daemonPidPath(dir)} { + data, err := os.ReadFile(p) + if err != 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 - 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 + return nil, nil } // 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 // 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. -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 { 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(), "DWS_CONNECT_DAEMON_DIRKEY="+dirKey, "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 != "" { 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) { fmt.Fprintf(w, "connect daemon started (pid %d)\n", pid) fmt.Fprintf(w, " logs: %s\n", logPath) - fmt.Fprintf(w, " status: dws devapp robot connect status%s\n", statusHintArgs(clientID, dirKey)) - fmt.Fprintf(w, " stop: dws devapp robot connect stop%s\n", statusHintArgs(clientID, dirKey)) + fmt.Fprintf(w, " status: dws dev connect status%s\n", statusHintArgs(clientID, dirKey)) + fmt.Fprintf(w, " stop: dws dev connect stop%s\n", statusHintArgs(clientID, dirKey)) fmt.Fprint(w, connectLocalDebugNotice()) } @@ -291,16 +315,26 @@ func runSupervisor(cmd *cobra.Command) error { connectDaemonDirOverride = v } 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) if err != nil { return apperrors.NewInternal("create daemon dir: " + err.Error()) } st := daemonState{ - Pid: os.Getpid(), - StartUnix: time.Now().Unix(), - LogPath: daemonLogPath(dir), - DirKey: dirKey, - ClientID: clientID, + Pid: os.Getpid(), + StartUnix: time.Now().Unix(), + LogPath: daemonLogPath(dir), + DirKey: dirKey, + ClientID: clientID, + UnifiedAppID: unifiedAppID, + Channel: channel, + NotifyStaffID: notifyStaffID, + Profile: profile, + AlwaysOn: alwaysOn, } if err := writeDaemonState(dir, st); err != nil { return apperrors.NewInternal("write daemon pid file: " + err.Error()) @@ -331,16 +365,19 @@ func runSupervisor(cmd *cobra.Command) error { } failures := 0 + daemonNotifyStateChange(notifyStaffID, channel, clientID, "started", "") for { if delay := backoffDelay(failures, daemonBackoffBase, daemonBackoffCap); delay > 0 { fmt.Fprintf(out, "[daemon] restarting worker in %s (consecutive failures: %d)\n", delay, failures) select { case <-ctx.Done(): + daemonNotifyStateChange(notifyStaffID, channel, clientID, "stopped", "") return nil case <-time.After(delay): } } if ctx.Err() != nil { + daemonNotifyStateChange(notifyStaffID, channel, clientID, "stopped", "") return nil } @@ -353,6 +390,7 @@ func runSupervisor(cmd *cobra.Command) error { fmt.Fprintf(out, "[daemon] failed to start worker: %v\n", err) failures++ 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") } continue @@ -365,6 +403,13 @@ func runSupervisor(cmd *cobra.Command) error { if ctx.Err() != nil { // We were asked to stop; the worker has been (or is being) signalled. 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 } @@ -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) 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))) } + 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. -func daemonStatus(w io.Writer, dirKey string) error { +// daemonStatus reports connector health to w. It combines two independent +// 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) if err != nil { return apperrors.NewInternal("resolve daemon dir: " + err.Error()) @@ -411,23 +462,60 @@ func daemonStatus(w io.Writer, dirKey string) error { if err != nil { return apperrors.NewInternal(err.Error()) } - if st == nil { - fmt.Fprintf(w, "connect daemon: not running (no pid file under %s)\n", dir) + supervised := st != nil && st.Pid > 0 && processAlive(st.Pid) + + 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 } - 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)) - return nil + + var lines []string + 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) - fmt.Fprintf(w, "connect daemon: running\n") - 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) + if report.Pid > 0 { + lines = append(lines, fmt.Sprintf("%s %s", tui.Key("pid"), tui.White(fmt.Sprintf("%d", report.Pid)))) } - 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 @@ -448,6 +536,28 @@ func daemonStop(w io.Writer, dirKey string) error { } if !processAlive(st.Pid) { _ = 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) return nil } @@ -483,7 +593,7 @@ func daemonStop(w io.Writer, dirKey string) error { func newDevAppRobotConnectStatusCommand() *cobra.Command { cmd := &cobra.Command{ Use: "status", - Short: "查看后台连接器守护进程状态(pid、运行时长、日志路径)", + Short: "查看连接器健康状态(healthy/degraded/down,pid、收发活动、日志路径;--json 供外部托管消费)", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, _ []string) error { @@ -491,12 +601,14 @@ func newDevAppRobotConnectStatusCommand() *cobra.Command { if err != nil { return err } - return daemonStatus(cmd.OutOrStdout(), dirKey) + jsonOut, _ := cmd.Flags().GetBool("json") + return daemonStatus(cmd.OutOrStdout(), dirKey, jsonOut) }, } preferLegacyLeaf(cmd) cmd.Flags().String("robot-client-id", "", "机器人 clientId(定位守护进程)") cmd.Flags().String("unified-app-id", "", "统一应用 ID(当未用 clientId 起守护进程时定位)") + cmd.Flags().Bool("json", false, "以 JSON 输出健康报告(供 launchd/systemd/pm2/cron 判断是否重启)") return cmd } @@ -523,6 +635,209 @@ func newDevAppRobotConnectStopCommand() *cobra.Command { 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 ` 重新启动,之后 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 // status/stop flags, requiring at least one identifier. func connectDaemonDirKeyFromFlags(cmd *cobra.Command) (string, error) { @@ -534,3 +849,151 @@ func connectDaemonDirKeyFromFlags(cmd *cobra.Command) (string, error) { } 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() + }() +} diff --git a/internal/helpers/connect_daemon_test.go b/internal/helpers/connect_daemon_test.go index d0187e72..97654bb5 100644 --- a/internal/helpers/connect_daemon_test.go +++ b/internal/helpers/connect_daemon_test.go @@ -15,6 +15,7 @@ package helpers import ( "bytes" + "encoding/json" "os" "strings" "testing" @@ -127,7 +128,7 @@ func TestDaemonStateRoundTrip(t *testing.T) { if st, err := readDaemonState(dir); err != nil || st != nil { 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 { 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 { 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) { @@ -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) { connectDaemonDirOverride = t.TempDir() t.Cleanup(func() { connectDaemonDirOverride = "" }) 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) } - if !strings.Contains(buf.String(), "not running") { - t.Errorf("expected 'not running', got %q", buf.String()) + if !strings.Contains(buf.String(), healthNotRunning) { + t.Errorf("expected %q, got %q", healthNotRunning, buf.String()) } } -func TestDaemonStatusStalePid(t *testing.T) { +func TestDaemonStatusConnectorDown(t *testing.T) { connectDaemonDirOverride = t.TempDir() t.Cleanup(func() { connectDaemonDirOverride = "" }) - dir, _ := connectDaemonDir("stale") - // pid that is essentially certain to be dead. - writeDaemonState(dir, daemonState{Pid: deadPid(t), StartUnix: time.Now().Unix(), LogPath: "/l", DirKey: "stale"}) + // Heartbeat from a connector whose process is gone: down. + seedHeartbeat(t, "gone", connectHeartbeat{Pid: deadPid(t), StartUnix: time.Now().Unix() - 100, ConnectedUnix: time.Now().Unix() - 90}) var buf bytes.Buffer - if err := daemonStatus(&buf, "stale"); err != nil { + if err := daemonStatus(&buf, "gone", false); err != nil { t.Fatalf("daemonStatus: %v", err) } - if !strings.Contains(buf.String(), "stale pid file") { - t.Errorf("expected stale pid report, got %q", buf.String()) + if !strings.Contains(buf.String(), healthDown) { + t.Errorf("expected %q, got %q", healthDown, buf.String()) } } -func TestDaemonStatusRunning(t *testing.T) { +func TestDaemonStatusHealthy(t *testing.T) { connectDaemonDirOverride = t.TempDir() t.Cleanup(func() { connectDaemonDirOverride = "" }) + // Live connector (our pid) that connected: healthy. Also file a live + // supervisor to exercise the supervised label. 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"}) + 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 - if err := daemonStatus(&buf, "live"); err != nil { + if err := daemonStatus(&buf, "live", false); err != nil { t.Fatalf("daemonStatus: %v", err) } 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) { 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) { connectDaemonDirOverride = t.TempDir() t.Cleanup(func() { connectDaemonDirOverride = "" }) diff --git a/internal/helpers/connect_fdlimit_unix.go b/internal/helpers/connect_fdlimit_unix.go new file mode 100644 index 00000000..1fc55d89 --- /dev/null +++ b/internal/helpers/connect_fdlimit_unix.go @@ -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) + } +} diff --git a/internal/helpers/connect_fdlimit_windows.go b/internal/helpers/connect_fdlimit_windows.go new file mode 100644 index 00000000..dbe65098 --- /dev/null +++ b/internal/helpers/connect_fdlimit_windows.go @@ -0,0 +1,5 @@ +//go:build windows + +package helpers + +func checkFDLimit() {} diff --git a/internal/helpers/connect_health.go b/internal/helpers/connect_health.go new file mode 100644 index 00000000..ee223e42 --- /dev/null +++ b/internal/helpers/connect_health.go @@ -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//. 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 /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// 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 +} diff --git a/internal/helpers/connect_health_test.go b/internal/helpers/connect_health_test.go new file mode 100644 index 00000000..302b8f30 --- /dev/null +++ b/internal/helpers/connect_health_test.go @@ -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) + } +} diff --git a/internal/helpers/connect_media.go b/internal/helpers/connect_media.go index bb663ba6..ba2703ca 100644 --- a/internal/helpers/connect_media.go +++ b/internal/helpers/connect_media.go @@ -22,6 +22,7 @@ import ( "net/url" "os" "path/filepath" + "strconv" "strings" "github.com/google/uuid" @@ -47,6 +48,212 @@ func pictureDownloadCode(content interface{}) string { 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 "" + } + b, err := json.Marshal(content) + if err != nil { + return fmt.Sprintf("", 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 // local temp file via the robot messageFiles/download API. Error-screenshot // 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 } +// 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 // back to the URL path, then ".png" (DingTalk screenshots default to png). func mediaExt(rawURL, contentType string) string { diff --git a/internal/helpers/connect_media_test.go b/internal/helpers/connect_media_test.go index ab29d6bd..a04db835 100644 --- a/internal/helpers/connect_media_test.go +++ b/internal/helpers/connect_media_test.go @@ -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 // fake API: token → messageFiles/download (must carry robotCode+downloadCode) // → 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 != "" { + 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) + } +} diff --git a/internal/helpers/connect_opencode.go b/internal/helpers/connect_opencode.go index a66932fc..904d804a 100644 --- a/internal/helpers/connect_opencode.go +++ b/internal/helpers/connect_opencode.go @@ -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) { - ctx, cancel := context.WithTimeout(ctx, f.timeout) + ctx, cancel := applyTimeout(ctx, f.timeout) defer cancel() client, err := f.server.ensure(ctx) @@ -204,10 +204,9 @@ func newOpencodeServer(bin string, env []string, workDir string) *opencodeServer bin: bin, env: env, workDir: workDir, - // No client-level Timeout: a turn can legitimately run for minutes, so the - // per-request ctx (forwardStream's f.timeout, default 300s) governs instead. - // A hardcoded 30s here used to abort long agent replies mid-flight with - // "Client.Timeout exceeded while awaiting headers". + // No client-level Timeout: a turn can legitimately run for minutes. + // f.timeout (from --agent-timeout / DWS_AGENT_TIMEOUT_MS, default 0 = + // no limit) governs via the per-request ctx when set. httpClient: &http.Client{}, } } diff --git a/internal/helpers/connect_opencode_test.go b/internal/helpers/connect_opencode_test.go index 9e0e6147..f913358c 100644 --- a/internal/helpers/connect_opencode_test.go +++ b/internal/helpers/connect_opencode_test.go @@ -16,6 +16,7 @@ package helpers import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "path/filepath" @@ -246,17 +247,17 @@ func TestOpencodeSessionsPersist(t *testing.T) { } } -// TestNewOpencodeServerHasNoClientTimeout pins the fix: the shared HTTP client -// must not carry an overall deadline, otherwise long agent turns get aborted -// with "Client.Timeout exceeded while awaiting headers". The per-turn ctx is -// the only thing allowed to bound a message round-trip. +// TestNewOpencodeServerHasNoClientTimeout pins: the shared HTTP client +// must not carry an overall deadline — long agent turns would be aborted +// mid-flight. The per-turn ctx (f.timeout, default 0 = no limit) governs +// only when the user explicitly sets --agent-timeout / DWS_AGENT_TIMEOUT_MS. func TestNewOpencodeServerHasNoClientTimeout(t *testing.T) { s := newOpencodeServer("opencode", nil, "") if s.httpClient == nil { t.Fatal("httpClient must be initialized") } 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. var _ sessionClearer = (*opencodeForwarder)(nil) diff --git a/internal/helpers/connect_qoder_stream.go b/internal/helpers/connect_qoder_stream.go index e2cf95b2..83c39462 100644 --- a/internal/helpers/connect_qoder_stream.go +++ b/internal/helpers/connect_qoder_stream.go @@ -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) { - ctx, cancel := context.WithTimeout(ctx, f.timeout) + ctx, cancel := applyTimeout(ctx, f.timeout) defer cancel() f.mu.Lock() diff --git a/internal/helpers/connect_stream.go b/internal/helpers/connect_stream.go index c212121b..351f18e3 100644 --- a/internal/helpers/connect_stream.go +++ b/internal/helpers/connect_stream.go @@ -16,6 +16,7 @@ package helpers import ( "context" "encoding/json" + "errors" "fmt" "os" "os/exec" @@ -187,6 +188,9 @@ type connectAgentOptions struct { // "remember" asks once per action kind then reuses that decision. Sourced // from RoleConfig.confirm_policy; empty = manual. 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 @@ -239,7 +243,7 @@ func (f *execForwarder) label() string { } 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() // 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 @@ -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 // tuning (--agent-model / --agent-workdir / --agent-memory). 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] if !ok { 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 { extras = &connectExtras{} } + checkFDLimit() if closer, ok := fwd.(forwarderCloser); ok { defer func() { if err := closer.close(); err != nil { @@ -925,6 +933,12 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str } 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{}) }) replier := chatbot.NewChatbotReplier() dedup := newMsgDedup(10000) @@ -936,19 +950,58 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str 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( client.WithAppCredential(client.NewAppCredentialConfig(clientID, clientSecret)), client.WithAutoReconnect(true), + client.WithKeepAlive(keepAlive), ) cli.RegisterChatBotCallbackRouter(func(_ context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) { text := strings.TrimSpace(data.Text.Content) + msgtype := strings.TrimSpace(data.Msgtype) // Picture messages carry no text — their payload is a downloadCode // resolved to a local file in the forward goroutine below. picCode := "" - if strings.EqualFold(strings.TrimSpace(data.Msgtype), "picture") { + if strings.EqualFold(msgtype, "picture") { 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 } // 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) } shown := text - if shown == "" { + if shown == "" && picCode != "" { 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", sender, truncateRunes(shown, 80), data.ConversationType, data.ConversationId, data.SenderStaffId, data.MsgId) + health.onPush() // Ack-first: return now, reply asynchronously via sessionWebhook (which is // independent of the Stream ack). Use a background context so the in-flight // 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 + ",请结合图片内容回答。)" } } + 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 { prompt = extras.kb.augment(prompt) } @@ -1080,8 +1191,14 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str // ② agent runs; // ③ on done: deliver the AI card with the final content; // ④ 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 - if cardCli != nil { + if cardCli != nil && canReact { if terr := cardCli.markThinking(context.Background(), callbackData.ConversationId, msgID); terr != nil { fmt.Fprintf(os.Stderr, "[connect][card] Thinking 表态失败(不影响回复): %v\n", terr) } else { @@ -1117,9 +1234,20 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str } if err != nil { 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 { 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 @@ -1173,22 +1301,40 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str if !delivered { // 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 - if len([]rune(reply)) > 200 { - sendErr = replier.SimpleReplyMarkdown(context.Background(), webhook, []byte(channel), []byte(reply)) - } else { - sendErr = replier.SimpleReplyText(context.Background(), webhook, []byte(reply)) + backoffs := []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second} + for attempt := 0; attempt <= len(backoffs); attempt++ { + if len([]rune(reply)) > 200 { + 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 { - 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 { fmt.Fprintf(os.Stderr, "[connect] 普通消息已发送 (%s, msgId=%s)\n", channel, msgID) + delivered = true } } 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 @@ -1208,6 +1354,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str return apperrors.NewInternal("stream 建连失败:" + err.Error()) } defer cli.Close() + health.onConnected() <-ctx.Done() return nil } @@ -1231,6 +1378,16 @@ func envDurationMS(key string, def time.Duration) time.Duration { 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. func truncateRunes(s string, max int) string { r := []rune(s) diff --git a/internal/helpers/connect_stream_test.go b/internal/helpers/connect_stream_test.go index 07f4a544..9b64f167 100644 --- a/internal/helpers/connect_stream_test.go +++ b/internal/helpers/connect_stream_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) // 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) + } +} diff --git a/internal/helpers/connect_streaming.go b/internal/helpers/connect_streaming.go index 646e291d..11b36e64 100644 --- a/internal/helpers/connect_streaming.go +++ b/internal/helpers/connect_streaming.go @@ -50,7 +50,7 @@ func (f *execForwarder) forwardStream(ctx context.Context, convID, text string, if !f.canStream() || onDelta == nil { return f.forward(ctx, convID, text) } - ctx, cancel := context.WithTimeout(ctx, f.timeout) + ctx, cancel := applyTimeout(ctx, f.timeout) defer cancel() var args []string diff --git a/internal/helpers/devapp.go b/internal/helpers/devapp.go index f39a72c1..ecf686f2 100644 --- a/internal/helpers/devapp.go +++ b/internal/helpers/devapp.go @@ -1611,7 +1611,7 @@ func normalizeDevAppRobotResult(content map[string]any) { devAppMarkVersionPublishBlocked(content) } if localConnectReady { - steps = append(steps, devAppRobotConnectStep(clientID)) + steps = append(steps, devAppRobotConnectStep(clientID, unifiedAppID)) } case "FAIL": lifecycle["phase"] = "failed" @@ -1725,14 +1725,29 @@ func devAppRobotRetryStep(taskID string, reuseTaskID bool) map[string]any { }) } -func devAppRobotConnectStep(clientID string) map[string]any { - if clientID == "" { - clientID = "" +// devAppRobotConnectStep advertises the local-debug connect command. The +// preferred form is `--unified-app-id`, which reuses `dev app credentials get` +// 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 = "" + } + command = fmt.Sprintf("dws dev connect --robot-client-id %s --format json", clientID) + doneWhen = "本地 Stream 建联成功;建议改用 --unified-app-id ,避免 clientSecret 出现在命令行被 ps 看到" } step := devAppNextStep(devAppStep{ ID: "connect_local", - Command: fmt.Sprintf("dws dev connect --robot-client-id %s --robot-client-secret --format json", clientID), - DoneWhen: "本地 Stream 建联成功,进程保持运行;clientSecret 只使用返回值,不写入命令建议", + Command: command, + DoneWhen: doneWhen, }) step["sensitiveFields"] = []string{"clientSecret"} step["optional"] = true diff --git a/internal/helpers/devapp_connect.go b/internal/helpers/devapp_connect.go index af3915db..7b94f02d 100644 --- a/internal/helpers/devapp_connect.go +++ b/internal/helpers/devapp_connect.go @@ -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 ,由 dev app credentials get 后台取密钥。") + } + if commandDryRun(cmd) { return writeCommandPayload(cmd, connectPreviewEnvelope(map[string]any{ "channel": channel, @@ -535,7 +545,10 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command { // connector alive 7x24. We resolve credentials/channel first (above) so // the parent fails fast on bad input before forking, then re-exec. 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) @@ -546,7 +559,8 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command { preferLegacyLeaf(cmd) // Daemon mode (see connect_daemon.go). --daemon detaches the connector into a // 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. 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)") @@ -555,6 +569,8 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command { cmd.AddCommand( newDevAppRobotConnectStatusCommand(), 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("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-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().Int("agent-timeout", 0, "每次 agent 调用的超时时间(秒),0=不限制(默认);env: DWS_AGENT_TIMEOUT_MS(毫秒)") 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("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("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("notify-staff-id", "", "状态通知 staffId:机器人启动/停止/崩溃时自动发钉钉消息通知此人;env: DWS_NOTIFY_STAFF_ID") return cmd } @@ -595,6 +613,7 @@ func connectAgentOptionsFromCommand(cmd *cobra.Command) connectAgentOptions { workDir = strings.TrimSpace(os.Getenv("DWS_AGENT_WORKDIR")) } memory, _ := cmd.Flags().GetBool("agent-memory") + agentTimeoutSec, _ := cmd.Flags().GetInt("agent-timeout") replyCard, _ := cmd.Flags().GetBool("reply-card") // Env kill-switch for scripted/service runs: DWS_REPLY_CARD=0 disables // cards regardless of the flag default. @@ -658,6 +677,7 @@ func connectAgentOptionsFromCommand(cmd *cobra.Command) connectAgentOptions { auditSheetTab = "Sheet1" } return connectAgentOptions{Model: model, WorkDir: workDir, Memory: memory, + Timeout: time.Duration(agentTimeoutSec) * time.Second, ReplyCard: replyCard, CardTemplate: cardTemplate, KnowledgeDir: knowledgeDir, KnowledgeSource: knowledgeSource, diff --git a/internal/helpers/devapp_robot_connect_test.go b/internal/helpers/devapp_robot_connect_test.go index b95f9c17..63a6d02b 100644 --- a/internal/helpers/devapp_robot_connect_test.go +++ b/internal/helpers/devapp_robot_connect_test.go @@ -39,10 +39,12 @@ func TestDevAppRobotConnectRegistered(t *testing.T) { // dry-run path (which never launches the Stream connector). func TestDevAppRobotConnectValidation(t *testing.T) { cases := []struct { - name string - args []string - wantErr string - wantJSON []string // substrings expected in successful dry-run output + name string + args []string + wantErr string + 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", @@ -55,14 +57,16 @@ func TestDevAppRobotConnectValidation(t *testing.T) { wantErr: "未知渠道", }, { - name: "explicit credentials dry-run emits plan", - 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\""}, + name: "explicit credentials dry-run emits plan and secret warning", + 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\""}, + wantStderr: []string{"[connect] WARNING", "--robot-client-secret 出现在命令行", "建议改用 --unified-app-id"}, }, { - name: "unified-app-id dry-run skips credentials get", - 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\""}, + name: "unified-app-id dry-run skips credentials get and emits no secret warning", + 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\""}, + notStderr: []string{"[connect] WARNING"}, }, } @@ -89,6 +93,16 @@ func TestDevAppRobotConnectValidation(t *testing.T) { 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()) + } + } }) } } diff --git a/internal/helpers/devapp_test.go b/internal/helpers/devapp_test.go index 479b547a..20d5667b 100644 --- a/internal/helpers/devapp_test.go +++ b/internal/helpers/devapp_test.go @@ -1249,8 +1249,11 @@ func TestDevAppRobotResultSuccessAddsPublishAndConnectSteps(t *testing.T) { 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) } - if command, _ := connect["command"].(string); !strings.Contains(command, "--robot-client-secret ") { - t.Fatalf("connect command = %q, want clientSecret placeholder", command) + if command, _ := connect["command"].(string); !strings.Contains(command, "--unified-app-id u-1") { + 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") } diff --git a/scripts/dev/connect-watchdog.sh b/scripts/dev/connect-watchdog.sh new file mode 100755 index 00000000..8952a2e6 --- /dev/null +++ b/scripts/dev/connect-watchdog.sh @@ -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 [--dry-run] -- +# +# 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 [--dry-run] -- " >&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 diff --git a/scripts/dev/recipes/README.md b/scripts/dev/recipes/README.md new file mode 100644 index 00000000..024c591d --- /dev/null +++ b/scripts/dev/recipes/README.md @@ -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 [--dry-run] -- +``` + +- `--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 --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 --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 +``` diff --git a/scripts/dev/recipes/launchd.dws-connect.plist b/scripts/dev/recipes/launchd.dws-connect.plist new file mode 100644 index 00000000..b44a2fec --- /dev/null +++ b/scripts/dev/recipes/launchd.dws-connect.plist @@ -0,0 +1,39 @@ + + + + + + Label + com.dingtalk.dws.connect + ProgramArguments + + /absolute/path/to/scripts/dev/connect-watchdog.sh + --client-id + REPLACE_CLIENT_ID + -- + /usr/local/bin/dws + dev + connect + --robot-client-id + REPLACE_CLIENT_ID + --channel + opencode + --daemon + + EnvironmentVariables + + DWS_BIN + /usr/local/bin/dws + + RunAtLoad + + StartInterval + 300 + StandardOutPath + REPLACE_HOME/.dws/connect/REPLACE_CLIENT_ID/watchdog.log + StandardErrorPath + REPLACE_HOME/.dws/connect/REPLACE_CLIENT_ID/watchdog.log + + diff --git a/scripts/dev/recipes/pm2.ecosystem.config.js b/scripts/dev/recipes/pm2.ecosystem.config.js new file mode 100644 index 00000000..421955f4 --- /dev/null +++ b/scripts/dev/recipes/pm2.ecosystem.config.js @@ -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 * * * *', + }, + ], +} diff --git a/scripts/dev/recipes/systemd.dws-connect.service b/scripts/dev/recipes/systemd.dws-connect.service new file mode 100644 index 00000000..4730835c --- /dev/null +++ b/scripts/dev/recipes/systemd.dws-connect.service @@ -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 diff --git a/skills/mono/references/products/aitable.md b/skills/mono/references/products/aitable.md index f49cfb0c..a2dc9eee 100644 --- a/skills/mono/references/products/aitable.md +++ b/skills/mono/references/products/aitable.md @@ -316,7 +316,7 @@ dws aitable chart get --base-id --dashboard-id --chart- ```bash # 第一步:创建任务(按 scope 传必要参数) -dws aitable export data --base-id --scope table --table-id --format excel --timeout-ms 1000 +dws aitable export data --base-id --scope table --table-id --export-format excel --timeout-ms 1000 # 第二步:拿 taskId 继续轮询,直到返回 downloadUrl dws aitable export data --base-id --task-id --timeout-ms 3000 diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md index ec60f8dd..b7b1ff34 100644 --- a/skills/mono/references/products/chat.md +++ b/skills/mono/references/products/chat.md @@ -449,6 +449,7 @@ Flags: - 纯文本/Markdown 单聊发送时 `--user` 和 `--open-dingtalk-id` 都可用;传 `--user` 时直接走 userId 发送能力 - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) - --at-all 和 --at-open-dingtalk-ids 仅在 --group 群聊时生效,单聊时无效;当设置--at-all时,消息内容中一定要包含对应的占位符@all;当设置--at-open-dingtalk-ids openDingTalkId1,openDingTalkId2时,消息内容中一定要包含对应格式的占位符<@openDingTalkId1> <@openDingTalkId2> + - **@ 群内机器人**:务必使用 `dws chat group bots --group ` 返回的 openDingtalkId(群级别 ID,与全局搜索结果不同);全局 `chat bot find` 返回的 ID 无法正确 @ 机器人 - **换行符**:消息内容按 Markdown 渲染,换行有两层要求,缺一不可: 1. 必须使用**真实换行符**(Unicode `U+000A`),而非字面量字符串 `\n`(反斜杠 + 字母 n)。程序或大模型构造参数时,须确保已正确反转义;否则全部内容会渲染在同一行 2. Markdown 规范下**单个换行不产生换行效果**。需要换行时请使用:段落分隔(连续两个真实换行符 `\n\n`)、行尾两个空格 + 真实换行符(硬换行 `
`),或直接写 HTML 的 `
` 标签 diff --git a/skills/multi/dingtalk-aitable/references/aitable.md b/skills/multi/dingtalk-aitable/references/aitable.md index f49cfb0c..a2dc9eee 100644 --- a/skills/multi/dingtalk-aitable/references/aitable.md +++ b/skills/multi/dingtalk-aitable/references/aitable.md @@ -316,7 +316,7 @@ dws aitable chart get --base-id --dashboard-id --chart- ```bash # 第一步:创建任务(按 scope 传必要参数) -dws aitable export data --base-id --scope table --table-id --format excel --timeout-ms 1000 +dws aitable export data --base-id --scope table --table-id --export-format excel --timeout-ms 1000 # 第二步:拿 taskId 继续轮询,直到返回 downloadUrl dws aitable export data --base-id --task-id --timeout-ms 3000 diff --git a/skills/multi/dingtalk-chat/references/chat.md b/skills/multi/dingtalk-chat/references/chat.md index c30566e7..b772dcb4 100644 --- a/skills/multi/dingtalk-chat/references/chat.md +++ b/skills/multi/dingtalk-chat/references/chat.md @@ -444,6 +444,7 @@ Flags: - 纯文本/Markdown 单聊发送时 `--user` 和 `--open-dingtalk-id` 都可用;传 `--user` 时直接走 userId 发送能力 - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) - --at-all 和 --at-open-dingtalk-ids 仅在 --group 群聊时生效,单聊时无效;当设置--at-all时,消息内容中一定要包含对应的占位符@all;当设置--at-open-dingtalk-ids openDingTalkId1,openDingTalkId2时,消息内容中一定要包含对应格式的占位符<@openDingTalkId1> <@openDingTalkId2> + - **@ 群内机器人**:务必使用 `dws chat group bots --group ` 返回的 openDingtalkId(群级别 ID,与全局搜索结果不同);全局 `chat bot find` 返回的 ID 无法正确 @ 机器人 - **换行符**:消息内容按 Markdown 渲染,换行有两层要求,缺一不可: 1. 必须使用**真实换行符**(Unicode `U+000A`),而非字面量字符串 `\n`(反斜杠 + 字母 n)。程序或大模型构造参数时,须确保已正确反转义;否则全部内容会渲染在同一行 2. Markdown 规范下**单个换行不产生换行效果**。需要换行时请使用:段落分隔(连续两个真实换行符 `\n\n`)、行尾两个空格 + 真实换行符(硬换行 `
`),或直接写 HTML 的 `
` 标签 @@ -532,6 +533,7 @@ Flags: - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) - --at-user-ids 仅在 --group 群聊时生效,单聊时无效;设置时 --text 中需包含 @userId 对应文本 - --at-open-dingtalk-ids 仅在 --group 群聊时生效,单聊时无效;设置时 --text 中需包含 @openDingtalkId 对应文本 + - **@ 群内机器人**:务必使用 `dws chat group bots --group ` 返回的 openDingtalkId(群级别 ID,与全局搜索结果不同);全局 `chat bot find` 返回的 ID 无法正确 @ 机器人 - --at-all @所有人,仅群聊时生效;只需带上 --at-all flag 即可,服务端会自动处理 - userId 获取方式:`dws contact user search --query "姓名"` 搜人获取 userId - **换行符**:--text 按 Markdown 渲染,换行规则同 `chat message send`: diff --git a/skills/multi/dingtalk-dev/references/recipes.md b/skills/multi/dingtalk-dev/references/recipes.md index 07d88c5e..72887506 100644 --- a/skills/multi/dingtalk-dev/references/recipes.md +++ b/skills/multi/dingtalk-dev/references/recipes.md @@ -27,6 +27,17 @@ dev 的端到端任务都是「定位应用,改容器某节点,按审批需 5. 若走无绑定的 `robot submit/result`,只有结果返回明确 `unifiedAppId` 才能继续版本发布 6. 完成态与缺 `unifiedAppId`、`SELECT_APPROVER` 等门禁判定见 [SKILL.md](../SKILL.md)「核心规则」:建联成功 + 版本进入 `RELEASE`/`AUDIT`/`UNDER_REVIEW` 才算完成 +## 重启守护进程连接器(不存密钥) + +守护进程被 stop / kill / 崩溃后,通过持久化的 `unifiedAppId` 重新拉取密钥并重启,无需本地保存 AppSecret。 + +1. `dev connect --daemon --unified-app-id --channel ` 首次启动(`unifiedAppId` 和 `channel` 会写入 `~/.dws/connect//daemon.pid`) +2. `dev connect restart --unified-app-id ` 重启:自动 stop 旧进程 → 从 dev 平台拉取 AppKey/Secret → 重新建联 +3. `dev connect status --unified-app-id ` 确认恢复 `healthy` +4. 若 daemon.pid 未持久化 `unifiedAppId`(如用 `--robot-client-id` 直接启动的),restart 会提示改用 `--unified-app-id` 启动 + +注意:密钥不落盘,每次 restart 动态从开发者平台获取;`daemon.pid` 只存 `unifiedAppId`、`channel`、`clientId`(公开值)。 + ## 上传图片拿 mediaId(应用图标 / 机器人图标) 应用图标、机器人图标都靠 mediaId 指定,但 dev 命令集不含上传——mediaId 要调钉钉 OpenAPI 拿到: