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.
423 lines
20 KiB
Go
423 lines
20 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"
|
||
"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 ""
|
||
}
|