合并远端团队的 MCP 对齐与 CR 修复,与本地 dev 命令重构 + schema 实时拉取 + flag 对齐 MCP 参数名集成。 冲突解决要点: - 工具名常量:两边已收敛到同一组 op-app 真实工具名,取 dev 子树(ours)标识 - 保留 schema 能力:32 个 annotateDevAppTool 注解 + flag=kebab(MCP参数名)不变 - 吸收远端 CR 修复:normalizeDevAppServiceResult 解包 ServiceResult、 remove_dev_app_permissions 结果扁平化、生命周期工具状态标记 - 旧 dingtalk-devapp 技能目录保持删除(已重命名为 dingtalk-dev) - devdoc article search 统一 --page-size/pageSize,--size 留兼容别名 构建/vet/internal 测试全绿(28 包,0 失败)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
532 lines
26 KiB
Go
532 lines
26 KiB
Go
// 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"
|
||
"strconv"
|
||
"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
|
||
// dev command tree as `dws dev connect`. Provisioning (建号) is NOT
|
||
// duplicated here — it reuses the existing `dev app 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`,直接在钉钉里跟新机器人对话",
|
||
"回复打了 Done 表情却不显示/卡在'数据加载中':钉钉按 AI 助理应答窗口渲染回复,超窗的纯文本会被丢弃;回复慢的 agent 需在 hermes 侧启用 AI 卡片(config.yaml 配 platforms.dingtalk.extra.card_template_id),由卡片先占位再流式出字",
|
||
},
|
||
}
|
||
}
|
||
if spec, ok := agentSpecs[channel]; ok {
|
||
if channel == "codex" && codexAppServerPlanEnabled() {
|
||
return map[string]any{
|
||
"method": "stream-bridge-codex-app-server",
|
||
"summary": "Go 原生 Stream 建联,优先转发到本地 Codex app-server 的 thread/turn 协议;失败时降级 codex exec",
|
||
"steps": []string{
|
||
"自动定位 Codex CLI(DWS_AGENT_CMD > PATH),默认带 --skip-git-repo-check 避免空白工作目录失败",
|
||
"用 clientId/clientSecret 起 Stream,注册 TOPIC_ROBOT 回调",
|
||
"收到消息 → 按 conversationId 映射/恢复 Codex thread → turn/start 获取结构化增量与完成事件",
|
||
"app-server 失败时降级为 codex exec 一次性回复",
|
||
"经 AI 卡片或 sessionWebhook 把回复发回钉钉,并记录发送成功/失败",
|
||
},
|
||
}
|
||
}
|
||
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
|
||
}
|
||
var cardCli *aiCardClient
|
||
replyStyle := "text/markdown"
|
||
if opts.ReplyCard {
|
||
cardCli = newAICardClient(clientID, clientSecret, opts.CardTemplate)
|
||
if cardCli.hasTemplate() {
|
||
replyStyle = "ai-card(thinking→done, 失败回退普通消息)"
|
||
} else {
|
||
replyStyle = "text/markdown + thinking/done表态(配 --card-template 升级为卡片)"
|
||
}
|
||
}
|
||
extras := &connectExtras{}
|
||
if opts.KnowledgeDir != "" {
|
||
kb, kerr := loadKnowledgeBase(opts.KnowledgeDir)
|
||
if kerr != nil {
|
||
return kerr
|
||
}
|
||
extras.kb = kb
|
||
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 知识库已加载:%d 个片段(%s)\n", len(kb.chunks), opts.KnowledgeDir)
|
||
}
|
||
if len(opts.AllowedUsers) > 0 || len(opts.AllowedGroups) > 0 || opts.UserRateLimit > 0 {
|
||
extras.gate = newConnectGate(opts.AllowedUsers, opts.AllowedGroups, opts.UserRateLimit)
|
||
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 访问控制:用户白名单 %d、群白名单 %d、限流 %d 条/分钟/人\n",
|
||
len(opts.AllowedUsers), len(opts.AllowedGroups), opts.UserRateLimit)
|
||
}
|
||
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] channel=%s Go 原生 Stream 建联,转发到 %s,回复样式=%s(Ctrl-C 退出)\n", channel, fwd.label(), replyStyle)
|
||
return runStreamConnector(cmd.Context(), channel, clientID, clientSecret, fwd, cardCli, extras)
|
||
}
|
||
|
||
// 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 ""
|
||
}
|
||
|
||
// connectPreviewEnvelope wraps a connect dry-run preview in an envelope that
|
||
// mirrors the app-tree helper_invocation shape (kind + dry_run at a known top
|
||
// level), so an agent can parse "is this a dry-run preview" the same way across
|
||
// all dev commands. The connect-specific fields (channel/cli/connect/...) sit
|
||
// inside, since connect is a linking pre-check, not an MCP tool call.
|
||
func connectPreviewEnvelope(fields map[string]any) map[string]any {
|
||
fields["kind"] = "connect_preview"
|
||
fields["dry_run"] = true
|
||
return map[string]any{"invocation": fields}
|
||
}
|
||
|
||
// newDevAppRobotConnectCommand implements `dws dev 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 的 `get_dev_app_credentials` to fetch them) — and starts the
|
||
// channel-aware Stream connector in the foreground. It never provisions a robot;
|
||
// for 建号 run `dws dev app 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(复用 dev app credentials get 自动取凭证)。\n" +
|
||
"渠道由 --channel 显式指定,或运行时信号自动探测。\n" +
|
||
"缺凭证请先用 `dws dev app robot create` 建号拿 clientId/clientSecret。",
|
||
Example: " dws dev connect --channel workbuddy --robot-client-id <id> --robot-client-secret <secret>\n" +
|
||
" dws dev connect --unified-app-id <ID> --channel qoderwork\n" +
|
||
" dws dev 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 dev app'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(复用 dev app credentials get 自动取凭证)")
|
||
}
|
||
if commandDryRun(cmd) {
|
||
// Dry-run must not call the credentials tool; just preview routing.
|
||
return writeCommandPayload(cmd, connectPreviewEnvelope(map[string]any{
|
||
"channel": channel,
|
||
"detectedBy": detectedBy,
|
||
"credentialSource": "unified-app-id (credentials get, skipped in dry-run)",
|
||
"unifiedAppId": unifiedAppID,
|
||
"agent": connectAgentOptionsPayload(channel, opts),
|
||
"cli": connectCliStatus(channel),
|
||
"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, connectPreviewEnvelope(map[string]any{
|
||
"channel": channel,
|
||
"detectedBy": detectedBy,
|
||
"credentialSource": resolvedBy,
|
||
"clientId": clientID,
|
||
"agent": connectAgentOptionsPayload(channel, opts),
|
||
"cli": connectCliStatus(channel),
|
||
"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:复用 dev app 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 关闭)")
|
||
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")
|
||
cmd.Flags().String("allowed-users", "", "用户白名单 staffId(逗号分隔),配置后仅名单内用户可触发;env: DWS_ALLOWED_USERS")
|
||
cmd.Flags().String("allowed-groups", "", "群白名单 openConversationId(逗号分隔),配置后仅名单内群可触发;env: DWS_ALLOWED_GROUPS")
|
||
cmd.Flags().Int("user-rate-limit", 20, "单用户每分钟消息上限(防刷;每条消息都是一次 LLM 调用),0 关闭;env: DWS_USER_RATE_LIMIT")
|
||
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")
|
||
replyCard, _ := cmd.Flags().GetBool("reply-card")
|
||
// Env kill-switch for scripted/service runs: DWS_REPLY_CARD=0 disables
|
||
// cards regardless of the flag default.
|
||
if v := strings.ToLower(strings.TrimSpace(os.Getenv("DWS_REPLY_CARD"))); v == "0" || v == "false" {
|
||
replyCard = false
|
||
}
|
||
cardTemplate := devAppStringFlag(cmd, "card-template")
|
||
if cardTemplate == "" {
|
||
cardTemplate = strings.TrimSpace(os.Getenv("DWS_CARD_TEMPLATE"))
|
||
}
|
||
// "public" opts into the openclaw shared template explicitly — best-effort
|
||
// only, since shared templates may not render for every app.
|
||
if strings.EqualFold(cardTemplate, "public") {
|
||
cardTemplate = defaultAICardTemplateID
|
||
}
|
||
knowledgeDir := devAppStringFlag(cmd, "knowledge-dir")
|
||
if knowledgeDir == "" {
|
||
knowledgeDir = strings.TrimSpace(os.Getenv("DWS_KNOWLEDGE_DIR"))
|
||
}
|
||
users := devAppStringFlag(cmd, "allowed-users")
|
||
if users == "" {
|
||
users = os.Getenv("DWS_ALLOWED_USERS")
|
||
}
|
||
groups := devAppStringFlag(cmd, "allowed-groups")
|
||
if groups == "" {
|
||
groups = os.Getenv("DWS_ALLOWED_GROUPS")
|
||
}
|
||
rateLimit, _ := cmd.Flags().GetInt("user-rate-limit")
|
||
if !cmd.Flags().Changed("user-rate-limit") {
|
||
if v := strings.TrimSpace(os.Getenv("DWS_USER_RATE_LIMIT")); v != "" {
|
||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||
rateLimit = n
|
||
}
|
||
}
|
||
}
|
||
return connectAgentOptions{Model: model, WorkDir: workDir, Memory: memory,
|
||
ReplyCard: replyCard, CardTemplate: cardTemplate,
|
||
KnowledgeDir: knowledgeDir,
|
||
AllowedUsers: splitCommaList(users), AllowedGroups: splitCommaList(groups),
|
||
UserRateLimit: rateLimit}
|
||
}
|
||
|
||
// 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 channel == "codex" && codexAppServerPlanEnabled() {
|
||
if opts.Memory {
|
||
memory = "per-conversation-app-server"
|
||
} else {
|
||
memory = "disabled"
|
||
}
|
||
} else 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)"
|
||
}
|
||
replyStyle := "text/markdown"
|
||
if opts.ReplyCard {
|
||
if opts.CardTemplate != "" {
|
||
replyStyle = "ai-card"
|
||
} else {
|
||
replyStyle = "text/markdown + thinking/done表态(配 --card-template 升级为卡片)"
|
||
}
|
||
}
|
||
return map[string]any{"model": model, "workdir": workDir, "memory": memory, "replyStyle": replyStyle}
|
||
}
|
||
|
||
// devAppFetchCredentials reuses dev app 的 get_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_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,
|
||
devAppCredentialsGetTool,
|
||
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 ""
|
||
}
|