Files
dingtalk-workspace-cli/internal/helpers/devapp_connect.go
T
修雨 995d490a76 feat(devapp): give robot connect session memory, model and workdir tuning
Users reported the linked bot feels dumb. Root cause: the forwarder was
hard-pinned to a stateless one-shot haiku run from an empty directory. Expose
the trade-offs as command-set flags instead of hardcoding them:

- --agent-memory (default on): per-conversation session resume. First message
  of a DingTalk conversation mints a UUID and passes `--session-id`,
  follow-ups pass `--resume`, so multi-turn context survives. Only on CLIs
  with addressable sessions (claudecode/codebuddy/workbuddy — verified against
  their --help); qodercli has --resume but no --session-id, so the qoder
  family stays stateless. Broken sessions self-heal: on forward error the
  conversation mapping is dropped and the next message starts fresh.
- --agent-model / DWS_AGENT_MODEL: override the channel CLI's model via each
  spec's model flag (replaces claudecode's built-in haiku pin in place).
- --agent-workdir / DWS_AGENT_WORKDIR: run the agent from a knowledge
  directory for context, instead of the clean temp dir (which stays the
  default to keep cold-start inside DingTalk's reply window).

A DWS_AGENT_CMD override disables flag splicing entirely — that argv is
user-owned and we cannot know which flags it accepts. Dry-run now previews
the effective agent tuning (model/workdir/memory) per channel.
2026-06-10 11:24:54 +08:00

423 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (
"fmt"
"os"
"os/exec"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
// devapp_connect wires the channel-aware "建联" (linking) capability into the
// devapp command tree as `dws devapp robot connect`. Provisioning (建号) is NOT
// duplicated here — it reuses devapp's existing `robot create/submit/result`.
// This file holds only the linking half: detect the current agent channel, then
// start a Go-native in-process Stream that forwards @-bot messages to the local
// agent CLI. The Stream forwarder internals live in connect_stream.go.
// connectChannels is the set of supported channels: the external ones plus every
// exec-type agent in agentSpecs (kept in sync automatically).
var connectChannels = func() map[string]struct{} {
m := map[string]struct{}{"openclaw": {}, "hermes": {}}
for ch := range agentSpecs {
m[ch] = struct{}{}
}
return m
}()
// resolveConnectChannel resolves the current agent channel using "explicit wins,
// then signal fallback". Priority: --channel flag > DWS_AGENT_CHANNEL env var >
// each agent's known runtime signal. Returns the channel name and the basis for
// the decision (detectedBy, for troubleshooting).
//
// Signals (verified on real runtimes):
// - openclaw connector injects DINGTALK_AGENT=DING_DWS_CLAW.
// - WorkBuddy injects WORKBUDDY_CONFIG_DIR / WORKBUDDY_APP_NAME into spawned children.
// - QoderWork's qodercli injects QODERCLI_INTEGRATION_MODE=qoder_work (and neither QODER_CLI nor CLAUDECODE).
// - plain Qoder injects QODER_CLI=1 (it is a Claude Code fork, so also CLAUDECODE=1).
// - pure Claude Code injects only CLAUDECODE=1.
// - hermes uses the official channel, marked by HERMES_AGENT / HERMES.
func resolveConnectChannel(explicit string) (channel string, detectedBy string) {
if norm := strings.ToLower(strings.TrimSpace(explicit)); norm != "" && norm != "auto" {
return norm, "flag:--channel"
}
if v := strings.ToLower(strings.TrimSpace(os.Getenv("DWS_AGENT_CHANNEL"))); v != "" {
return v, "env:DWS_AGENT_CHANNEL"
}
// Signal fallback.
if strings.EqualFold(strings.TrimSpace(os.Getenv("DINGTALK_AGENT")), "DING_DWS_CLAW") {
return "openclaw", "signal:DINGTALK_AGENT"
}
if strings.TrimSpace(os.Getenv("OPENCLAW")) != "" || strings.TrimSpace(os.Getenv("OPENCLAW_GATEWAY")) != "" {
return "openclaw", "signal:OPENCLAW"
}
if strings.TrimSpace(os.Getenv("HERMES_AGENT")) != "" || strings.TrimSpace(os.Getenv("HERMES")) != "" {
return "hermes", "signal:HERMES"
}
// WorkBuddy(CodeBuddy) injects WORKBUDDY_CONFIG_DIR / WORKBUDDY_APP_NAME
// (verified, pointing at ~/.workbuddy) into the children it spawns. This is a
// WorkBuddy-specific runtime marker that does not leak globally, so dws can
// recognise the current host when WorkBuddy spawns it.
if strings.TrimSpace(os.Getenv("WORKBUDDY_CONFIG_DIR")) != "" || strings.TrimSpace(os.Getenv("WORKBUDDY_APP_NAME")) != "" {
return "workbuddy", "signal:WORKBUDDY_CONFIG_DIR"
}
// QoderWork's qodercli injects QODERCLI_INTEGRATION_MODE=qoder_work (verified)
// and carries neither QODER_CLI nor CLAUDECODE. Use it to split qoderwork out
// of the qoder family, avoiding "linking inside QoderWork but reaching Qoder".
// Must come before the QODER_CLI / CLAUDECODE checks below.
if strings.EqualFold(strings.TrimSpace(os.Getenv("QODERCLI_INTEGRATION_MODE")), "qoder_work") {
return "qoderwork", "signal:QODERCLI_INTEGRATION_MODE"
}
if strings.TrimSpace(os.Getenv("QODER_CLI")) != "" {
// Plain Qoder (AI coding IDE): carries QODER_CLI=1. Qoder is a Claude Code
// fork and also carries CLAUDECODE=1, so this check must precede CLAUDECODE
// below to avoid misdetecting it as claudecode.
return "qoder", "signal:QODER_CLI"
}
if strings.TrimSpace(os.Getenv("CLAUDECODE")) != "" {
// Pure Claude Code (not the qoder fork): only CLAUDECODE=1, no QODER_CLI.
return "claudecode", "signal:CLAUDECODE"
}
return "", "undetected"
}
// buildConnectPlan returns the linking plan that wires the bot to a channel's
// local agent. External channels (openclaw/hermes) have bespoke plans; every
// exec-type agent (agentSpecs) shares a generic Stream + headless-CLI plan. Used
// for the --dry-run preview.
func buildConnectPlan(channel, clientID, robotCode string) map[string]any {
switch channel {
case "openclaw":
return map[string]any{
"method": "openclaw-connector",
"summary": "走 dingtalk-openclaw-connector 官方建联,由连接器自己建号 + AI 卡片流式回复(dws 不代建机器人)",
"steps": []string{
"按 https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector 设备码扫码注册机器人",
"启动 openclaw gateway,由连接器处理消息收发与卡片渲染",
},
}
case "hermes":
return map[string]any{
"method": "official-channel",
"summary": "走 Hermes 官方 channel 建联,由 Hermes 自己建号 + 原生回复(dws 不代建机器人)",
"steps": []string{
"运行 `hermes gateway setup` → 选 DingTalk → QR Code Scan 扫码授权",
"`hermes gateway restart`,直接在钉钉里跟新机器人对话",
},
}
}
if spec, ok := agentSpecs[channel]; ok {
return map[string]any{
"method": "stream-bridge",
"summary": fmt.Sprintf("Go 原生 Stream 建联,转发到本地 %s 的无头 CLI(每条消息起一个新实例,可 7×24 无人值守)", spec.app),
"steps": []string{
"自动定位 agent CLI(DWS_AGENT_CMD > PATH > app 自带),缺包管理器装的会自动安装、装不了的提示安装",
"用 clientId/clientSecret 起 Stream,注册 TOPIC_ROBOT 回调",
"收到消息 → 调该 agent 的无头 CLI(如 claude -p / codex exec / codebuddy -p)→ stdout 作为回复",
"经 sessionWebhook 把回复发回钉钉",
},
}
}
return map[string]any{"method": "unknown"}
}
// connectExternalCommand returns the connector command (argv) for channels that
// must be launched by an external process. Resolution priority: the
// DWS_CONNECT_CMD env var (space-separated, for customisation/testing, applies
// to all channels) > openclaw's built-in gateway. The stream-bridge channels
// (qoder/qoderwork/claudecode/workbuddy) use the Go-native in-process Stream
// (see connect_stream.go) and return no external command (nil). hermes uses the
// official channel and also has no built-in external command. Pure function,
// side-effect free, for easy unit testing.
func connectExternalCommand(channel string) []string {
if v := strings.TrimSpace(os.Getenv("DWS_CONNECT_CMD")); v != "" {
return strings.Fields(v)
}
switch channel {
case "openclaw":
// openclaw is taken over by the external connector: write credentials into
// openclaw.json, then restart the gateway.
return []string{"openclaw", "gateway", "restart"}
default:
// stream-bridge channels go Go-native; hermes etc. have no built-in command
// and need DWS_CONNECT_CMD.
return nil
}
}
// launchConnector wires the bot to the local agent per channel, running in the
// foreground until interrupted. Dispatch priority:
// 1. external connector (DWS_CONNECT_CMD override or openclaw gateway) → os/exec
// child, credentials injected via CID/SEC/DWS_AGENT_CHANNEL;
// 2. stream-bridge channels (qoder/qoderwork/claudecode/workbuddy) → Go-native
// in-process Stream + forwarder, no node/external-script dependency;
// 3. others (hermes etc.) → no built-in linking, advise DWS_CONNECT_CMD.
func launchConnector(cmd *cobra.Command, channel, clientID, clientSecret string, opts connectAgentOptions) error {
if argv := connectExternalCommand(channel); len(argv) > 0 {
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] channel=%s 启动外部连接器: %s\n", channel, strings.Join(argv, " "))
proc := exec.CommandContext(cmd.Context(), argv[0], argv[1:]...)
proc.Env = append(os.Environ(),
"CID="+clientID,
"SEC="+clientSecret,
"DWS_AGENT_CHANNEL="+channel,
)
proc.Stdout = cmd.OutOrStdout()
proc.Stderr = cmd.ErrOrStderr()
return proc.Run()
}
if isStreamBridgeChannel(channel) {
fwd, err := forwarderForChannel(channel, opts)
if err != nil {
return err
}
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] channel=%s Go 原生 Stream 建联,转发到 %s(Ctrl-C 退出)\n", channel, fwd.label())
return runStreamConnector(cmd.Context(), channel, clientID, clientSecret, fwd)
}
// hermes / openclaw run their own official bot provisioning + reply logic
// (device-flow QR registration, AI-card streaming). dws must not provision
// a robot for them or wrap them in the Stream bridge — their bots are a
// different type and only render through their native pipeline. Point the
// user at the official tool instead.
if guidance := officialChannelGuidance(channel); guidance != "" {
fmt.Fprint(cmd.ErrOrStderr(), guidance)
return nil
}
return apperrors.NewValidation(fmt.Sprintf("渠道 %q 暂无内置建联;用环境变量 DWS_CONNECT_CMD 指定要运行的连接器", channel))
}
// officialChannelGuidance returns onboarding instructions for channels that own
// their bot lifecycle (hermes, openclaw). dws guides the user to the official
// tool rather than provisioning a robot itself, because a dws-provisioned
// "智能体" robot does not render through these channels' native reply path.
func officialChannelGuidance(channel string) string {
switch channel {
case "hermes":
return "[connect] hermes 渠道走 Hermes 官方建联,dws 不代建机器人:\n" +
" 1. 运行 `hermes gateway setup` → 选 DingTalk → QR Code Scan,用钉钉扫码授权\n" +
" (设备码注册,自动把 client_id/secret 写入 ~/.hermes/.env)\n" +
" 2. `hermes gateway restart`,直接在钉钉里跟新机器人对话\n"
case "openclaw":
return "[connect] openclaw 渠道走 dingtalk-openclaw-connector 官方建联,dws 不代建机器人:\n" +
" 1. 按 https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector 的设备码扫码注册机器人\n" +
" 2. 启动 openclaw gateway,由连接器处理 AI 卡片流式回复\n"
}
return ""
}
// newDevAppRobotConnectCommand implements `dws devapp robot connect`: the linking
// half of provisioning. It takes an existing robot's credentials — either
// directly via --client-id/--client-secret, or by --unified-app-id (reusing
// devapp's `get_open_dev_app_credentials` to fetch them) — and starts the
// channel-aware Stream connector in the foreground. It never provisions a robot;
// for 建号 run `dws devapp robot create` (or submit/result) first.
func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "connect",
Short: "建联:把现成机器人接到当前本地 agent(起 Stream,不建号)",
Long: "用已建好的机器人凭证把它接到当前本地 agent,不做建号。\n" +
"凭证两种来源:① 直接传 --robot-client-id/--robot-client-secret;② 传 --unified-app-id(复用 devapp credentials get 自动取凭证)。\n" +
"渠道由 --channel 显式指定,或运行时信号自动探测。\n" +
"缺凭证请先用 `dws devapp robot create` 建号拿 clientId/clientSecret。",
Example: " dws devapp robot connect --channel workbuddy --robot-client-id <id> --robot-client-secret <secret>\n" +
" dws devapp robot connect --unified-app-id <ID> --channel qoderwork\n" +
" dws devapp robot connect --channel claudecode --robot-client-id <id> --robot-client-secret <secret>",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
channelFlag := devAppStringFlag(cmd, "channel")
channel, detectedBy := resolveConnectChannel(channelFlag)
if channel == "" {
return apperrors.NewValidation("无法探测 agent 渠道;请用 --channel 指定 (openclaw|qoder|qoderwork|hermes|workbuddy|claudecode|codebuddy|codex|gemini|opencode) 或设置 DWS_AGENT_CHANNEL")
}
if _, ok := connectChannels[channel]; !ok {
return apperrors.NewValidation(fmt.Sprintf("未知渠道 %q(支持 openclaw|qoder|qoderwork|hermes|workbuddy|claudecode|codebuddy|codex|gemini|opencode)", channel))
}
clientID := devAppStringFlag(cmd, "robot-client-id")
clientSecret := devAppStringFlag(cmd, "robot-client-secret")
unifiedAppID := devAppStringFlag(cmd, "unified-app-id")
opts := connectAgentOptionsFromCommand(cmd)
// Credential resolution: explicit pair wins; otherwise reuse devapp's
// credentials get against --unified-app-id.
resolvedBy := "flag:--robot-client-id/--robot-client-secret"
if clientID == "" || clientSecret == "" {
if unifiedAppID == "" {
return apperrors.NewValidation("需要 --robot-client-id/--robot-client-secret(用现成机器人凭证),或 --unified-app-id(复用 devapp credentials get 自动取凭证)")
}
if commandDryRun(cmd) {
// Dry-run must not call the credentials tool; just preview routing.
return writeCommandPayload(cmd, map[string]any{
"channel": channel,
"detectedBy": detectedBy,
"dryRun": true,
"credentialSource": "unified-app-id (credentials get, skipped in dry-run)",
"unifiedAppId": unifiedAppID,
"agent": connectAgentOptionsPayload(channel, opts),
"connect": buildConnectPlan(channel, "", ""),
})
}
id, secret, err := devAppFetchCredentials(runner, cmd, unifiedAppID)
if err != nil {
return err
}
if id == "" || secret == "" {
return apperrors.NewInternal("credentials get 未返回 clientId/clientSecret;clientSecret 可能仅建号时返回一次,请改用 --robot-client-id/--robot-client-secret 直接传入")
}
clientID, clientSecret = id, secret
resolvedBy = "unified-app-id:credentials get"
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, map[string]any{
"channel": channel,
"detectedBy": detectedBy,
"dryRun": true,
"credentialSource": resolvedBy,
"clientId": clientID,
"agent": connectAgentOptionsPayload(channel, opts),
"connect": buildConnectPlan(channel, clientID, ""),
})
}
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] channel=%s(%s)凭证来源=%s\n", channel, detectedBy, resolvedBy)
return launchConnector(cmd, channel, clientID, clientSecret, opts)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("channel", "auto", "渠道:auto(默认,自动探测)|openclaw|qoder|qoderwork|hermes|workbuddy|claudecode|codebuddy|codex|gemini|opencode")
// 用 robot-client-* 而非 client-id/client-secret:后者是全局 OAuth 客户端覆盖
// 持久 flag(见 internal/app/flags.go),同名会 shadow 全局 flag。这里是要建联的
// 目标机器人凭证,与 OAuth 客户端是两码事,故独立命名避免撞名。
cmd.Flags().String("robot-client-id", "", "现成机器人 clientId(AppKey)")
cmd.Flags().String("robot-client-secret", "", "现成机器人 clientSecret(AppSecret)")
cmd.Flags().String("unified-app-id", "", "统一应用 ID:复用 devapp credentials get 自动取凭证(替代手填 robot-client-id/secret)")
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 会话上下文(claudecode/codebuddy/workbuddy 支持;--agent-memory=false 关闭)")
return cmd
}
// connectAgentOptionsFromCommand reads the agent tuning flags, falling back to
// the mirrored env vars so connectors run from scripts/services can be
// configured without flags.
func connectAgentOptionsFromCommand(cmd *cobra.Command) connectAgentOptions {
model := devAppStringFlag(cmd, "agent-model")
if model == "" {
model = strings.TrimSpace(os.Getenv("DWS_AGENT_MODEL"))
}
workDir := devAppStringFlag(cmd, "agent-workdir")
if workDir == "" {
workDir = strings.TrimSpace(os.Getenv("DWS_AGENT_WORKDIR"))
}
memory, _ := cmd.Flags().GetBool("agent-memory")
return connectAgentOptions{Model: model, WorkDir: workDir, Memory: memory}
}
// connectAgentOptionsPayload renders the effective agent tuning for the
// dry-run preview, including whether session memory actually applies to the
// chosen channel (the qoder family and codex/gemini/opencode are stateless —
// their CLIs have no addressable session ID).
func connectAgentOptionsPayload(channel string, opts connectAgentOptions) map[string]any {
spec, ok := agentSpecs[channel]
memory := "unsupported"
if ok && spec.ccSessions {
if opts.Memory {
memory = "per-conversation"
} else {
memory = "disabled"
}
}
model := opts.Model
if model == "" {
model = "(channel default)"
}
workDir := opts.WorkDir
if workDir == "" {
workDir = "(clean temp dir)"
}
return map[string]any{"model": model, "workdir": workDir, "memory": memory}
}
// devAppFetchCredentials reuses devapp's get_open_dev_app_credentials tool to
// resolve a unified app's clientId/clientSecret, so `robot connect
// --unified-app-id` need not have the caller paste raw credentials. Note the
// open platform only returns clientSecret once (at provisioning); if the tool
// omits it the caller is told to fall back to explicit flags.
//
// TODO(verify): the clientId/clientSecret (and appKey/appSecret fallback) field
// names below are NOT yet confirmed against the real get_open_dev_app_credentials
// response — no fixture exists in-repo. Verify against the pre-prod gateway and
// pin the exact field names before relying on --unified-app-id auto-fetch. The
// path degrades safely today: an unrecognised shape yields empty strings and the
// caller is told to use --robot-client-id/--robot-client-secret instead.
func devAppFetchCredentials(runner executor.Runner, cmd *cobra.Command, unifiedAppID string) (clientID, clientSecret string, err error) {
invocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
devAppProduct,
"get_open_dev_app_credentials",
map[string]any{"unifiedAppId": unifiedAppID},
)
res, err := runner.Run(cmd.Context(), invocation)
if err != nil {
return "", "", err
}
payload := devAppConnectUnwrap(res.Response)
clientID = devAppConnectFirst(payload, "clientId", "appKey", "clientID")
clientSecret = devAppConnectFirst(payload, "clientSecret", "appSecret")
return clientID, clientSecret, nil
}
// devAppConnectUnwrap descends the executor/MCP envelope
// (Response{"content":{...,"result":{...}}}) to the innermost object, tolerating
// either wrapper being absent.
func devAppConnectUnwrap(resp map[string]any) map[string]any {
cur := resp
if cur == nil {
return nil
}
if inner, ok := cur["content"].(map[string]any); ok {
cur = inner
}
if inner, ok := cur["result"].(map[string]any); ok {
cur = inner
}
return cur
}
// devAppConnectFirst returns the first non-empty string value among the given
// keys, tolerating nil maps and non-string scalars.
func devAppConnectFirst(resp map[string]any, keys ...string) string {
if resp == nil {
return ""
}
for _, key := range keys {
if v, ok := resp[key].(string); ok {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
}
return ""
}