diff --git a/internal/app/direct_runtime.go b/internal/app/direct_runtime.go index b46e0f9f..153ec091 100644 --- a/internal/app/direct_runtime.go +++ b/internal/app/direct_runtime.go @@ -47,6 +47,19 @@ const ( defaultPATServerID = "abc3c880fb90f04b52d1426aaf093766e5fc9ec38411688cbb74df42a584d374" ) +// Hardcoded built-in endpoint for the DingTalk open-platform app-management MCP +// server, which hosts the async robot-provisioning tools submit_robot_create_task +// and query_robot_create_result. This is +// wired by source (NOT service discovery) per product decision: the helper +// command `dws chat bot create` routes CanonicalProduct "opendev" here. It is a +// named gateway alias (no ?key=), so the call is authenticated by the caller's +// session bearer token — the created robot is owned by the current login. Points +// at the production open-platform gateway. +const ( + robotCreateProductID = "opendev" + robotCreateEndpoint = "https://mcp-gw.dingtalk.com/server/op-app" +) + func defaultPATServerDescriptor() market.ServerDescriptor { return market.ServerDescriptor{ Key: defaultPATProductID, @@ -241,6 +254,17 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) { } } + // Hardcoded built-in: the robot-provisioning product is pinned to a fixed + // MCP server in source (NOT service discovery), per product decision. Placed + // after the env-var override but before the dynamic/discovery lookups so the + // pinned endpoint stays authoritative even if a same-named product later + // shows up in discovery. + for _, candidate := range []string{strings.TrimSpace(productID), normalized} { + if candidate == robotCreateProductID { + return robotCreateEndpoint, true + } + } + dynamicMu.RLock() de := dynamicEndpoints te := dynamicToolEndpoints diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 292b103f..c4e66b75 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -16,7 +16,9 @@ package helpers import ( "context" "encoding/json" + "fmt" "strings" + "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" @@ -117,6 +119,7 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command { bot.AddCommand( newChatBotFindCommand(runner), newChatBotSearchCommand(runner), + newChatBotCreateCommand(runner), ) root.AddCommand(message, group, bot) @@ -140,6 +143,238 @@ func botInvoke(runner executor.Runner, cmd *cobra.Command, tool string, params m return writeCommandPayload(cmd, result) } +// Robot provisioning is a two-step async flow on the open-platform +// app-management MCP server: submit_robot_create_task returns a taskId, then +// query_robot_create_result is polled until the task reaches a terminal state. +// The async pair (replacing the old one-shot create_dingtalk_robot) lets the +// server dedupe by taskId so a retry never creates a second robot. +const ( + robotCreateSubmitTool = "submit_robot_create_task" + robotCreateQueryTool = "query_robot_create_result" + + // Poll cadence guards: honor the server-provided interval but keep it sane. + robotCreatePollMinInterval = 1 * time.Second + robotCreatePollMaxInterval = 30 * time.Second + // Fallbacks when the submit response omits interval / expiresIn (seconds). + robotCreateDefaultInterval = 3 * time.Second + robotCreateDefaultDeadline = 5 * time.Minute +) + +// runRobotCreateTool routes a robot-provisioning tool to the open-platform +// app-management MCP server via CanonicalProduct "opendev". That product's +// endpoint is hardcoded in internal/app/direct_runtime.go (NOT resolved from +// service discovery), so this command works without any discovery/overlay entry. +func runRobotCreateTool(runner executor.Runner, cmd *cobra.Command, tool string, params map[string]any, dryRun bool) (executor.Result, error) { + invocation := executor.NewHelperInvocation( + cobracmd.LegacyCommandPath(cmd), + "opendev", + tool, + params, + ) + invocation.DryRun = dryRun + return runner.Run(cmd.Context(), invocation) +} + +// robotCreateProvision submits an async robot-create task and polls for its +// result until SUCCESS / FAIL / EXPIRED (or the deadline). On success it writes +// the full query payload (agentId / robotCode / clientId / clientSecret). On +// FAIL / EXPIRED it returns an error carrying the taskId so the caller can retry +// with --task-id without creating a duplicate robot. +func robotCreateProvision(runner executor.Runner, cmd *cobra.Command, submitParams map[string]any) error { + dryRun := commandDryRun(cmd) + + submitRes, err := runRobotCreateTool(runner, cmd, robotCreateSubmitTool, submitParams, dryRun) + if err != nil { + return err + } + // Dry-run only previews the submit routing; there is no real taskId to poll. + if dryRun { + return writeCommandPayload(cmd, submitRes) + } + + submitPayload := robotCreatePayload(submitRes.Response) + taskID := robotResultString(submitPayload, "taskId") + if taskID == "" { + // Server returned an inline (already-terminal) result without a taskId; + // surface it verbatim rather than poll a task that does not exist. + return writeCommandPayload(cmd, submitRes) + } + + interval := robotResultDuration(submitPayload, "interval", robotCreateDefaultInterval) + if interval < robotCreatePollMinInterval { + interval = robotCreatePollMinInterval + } + if interval > robotCreatePollMaxInterval { + interval = robotCreatePollMaxInterval + } + deadline := robotResultDuration(submitPayload, "expiresIn", robotCreateDefaultDeadline) + + ctx := cmd.Context() + elapsed := time.Duration(0) + queryParams := map[string]any{"taskId": taskID} + for { + if err := robotCreateSleepFn(ctx, interval); err != nil { + return err + } + elapsed += interval + + queryRes, err := runRobotCreateTool(runner, cmd, robotCreateQueryTool, queryParams, false) + if err != nil { + return err + } + queryPayload := robotCreatePayload(queryRes.Response) + switch strings.ToUpper(robotResultString(queryPayload, "status")) { + case "SUCCESS": + return writeCommandPayload(cmd, queryRes) + case "FAIL", "EXPIRED": + status := strings.ToUpper(robotResultString(queryPayload, "status")) + return apperrors.NewInternal(fmt.Sprintf( + "robot creation %s (taskId=%s); retry with: dws chat bot create ... --task-id %s", + status, taskID, taskID)) + case "WAITING", "": + // keep polling + default: + // Unknown terminal-ish status: surface the raw payload. + return writeCommandPayload(cmd, queryRes) + } + + if elapsed >= deadline { + return apperrors.NewInternal(fmt.Sprintf( + "robot creation still WAITING after %s (taskId=%s); check later or retry with: dws chat bot create ... --task-id %s", + deadline, taskID, taskID)) + } + } +} + +// robotCreateSleepFn is the poll-wait function; overridable in tests so the +// polling loop can run without real delays. +var robotCreateSleepFn = robotCreateSleep + +// robotCreateSleep waits for d or until the context is cancelled. +func robotCreateSleep(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +// robotCreatePayload unwraps the executor/MCP envelope so callers can read +// taskId / status / agentId from the innermost object. The real shape is +// Response{"content":{"errorCode","errorMsg","success","result":{...}}}, so we +// descend through "content" and then "result", tolerating either wrapper being +// absent. +func robotCreatePayload(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 +} + +// robotResultString reads a string field from an MCP response map, tolerating +// nil maps and non-string scalars. +func robotResultString(resp map[string]any, key string) string { + if resp == nil { + return "" + } + switch v := resp[key].(type) { + case string: + return strings.TrimSpace(v) + case fmt.Stringer: + return strings.TrimSpace(v.String()) + default: + return "" + } +} + +// robotResultDuration reads a numeric field as a second-count duration, falling +// back to def when the field is missing or unparseable. +func robotResultDuration(resp map[string]any, key string, def time.Duration) time.Duration { + if resp == nil { + return def + } + switch v := resp[key].(type) { + case float64: + if v > 0 { + return time.Duration(v) * time.Second + } + case int: + if v > 0 { + return time.Duration(v) * time.Second + } + case json.Number: + if n, err := v.Float64(); err == nil && n > 0 { + return time.Duration(n) * time.Second + } + } + return def +} + +// newChatBotCreateCommand creates `dws chat bot create`, the robot-provisioning +// command. It submits an async robot-create task (submit_robot_create_task) and +// blocks while polling query_robot_create_result until the task reaches a +// terminal state, then returns agentId / robotCode / clientId / clientSecret +// (clientSecret is returned only once). corpId and userid are injected +// server-side from the current login. If creation FAILs / EXPIREs, re-run with +// --task-id to retry without creating a duplicate robot. +func newChatBotCreateCommand(runner executor.Runner) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "创建钉钉智能体机器人", + Long: "创建企业自建 Agent 应用及承载机器人。服务端异步建号,本命令会阻塞轮询直到成功,返回 agentId / robotCode / clientId / clientSecret。⚠️ clientSecret 仅返回一次,请立即安全保存。corpId 和 userid 由 MCP 服务端按当前登录身份注入。建号失败时用 --task-id <上次返回的 taskId> 重试,可避免重复建号。", + Example: " dws chat bot create --app-name \"销售助手\" --robot-name \"销售助手机器人\" --desc \"销售线索查询与客户跟进\"", + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + appName, _ := cmd.Flags().GetString("app-name") + robotName, _ := cmd.Flags().GetString("robot-name") + desc, _ := cmd.Flags().GetString("desc") + if strings.TrimSpace(appName) == "" { + return apperrors.NewValidation("--app-name is required") + } + if strings.TrimSpace(robotName) == "" { + return apperrors.NewValidation("--robot-name is required") + } + if strings.TrimSpace(desc) == "" { + return apperrors.NewValidation("--desc is required") + } + params := map[string]any{ + "appName": appName, + "robotName": robotName, + "desc": desc, + } + if v, _ := cmd.Flags().GetString("robot-media-id"); strings.TrimSpace(v) != "" { + params["robotMediaId"] = v + } + if v, _ := cmd.Flags().GetString("preview-media-id"); strings.TrimSpace(v) != "" { + params["previewMediaId"] = v + } + if v, _ := cmd.Flags().GetString("task-id"); strings.TrimSpace(v) != "" { + params["taskId"] = strings.TrimSpace(v) + } + return robotCreateProvision(runner, cmd, params) + }, + } + preferLegacyLeaf(cmd) + cmd.Flags().String("app-name", "", "智能体应用名称,2~20 字,企业内唯一 (必填)") + cmd.Flags().String("robot-name", "", "承载机器人名称,2~20 字 (必填)") + cmd.Flags().String("desc", "", "机器人功能描述,≤200 字 (必填)") + cmd.Flags().String("robot-media-id", "", "机器人图标 mediaId(可选,留空用服务端默认图标)") + cmd.Flags().String("preview-media-id", "", "机器人预览图 mediaId(可选,留空复用 --robot-media-id)") + cmd.Flags().String("task-id", "", "重试用:上次建号返回的 taskId,避免重复建号(可选)") + return cmd +} + func newChatBotFindCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "find", diff --git a/internal/helpers/chat_bot_create_test.go b/internal/helpers/chat_bot_create_test.go new file mode 100644 index 00000000..475ebb5f --- /dev/null +++ b/internal/helpers/chat_bot_create_test.go @@ -0,0 +1,182 @@ +// 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 ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" +) + +// scriptedRunner returns a scripted Result per call so we can drive the +// submit → poll(query) loop deterministically. +type scriptedRunner struct { + calls []executor.Invocation + fn func(call int, inv executor.Invocation) executor.Result +} + +func (r *scriptedRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) { + idx := len(r.calls) + r.calls = append(r.calls, inv) + return r.fn(idx, inv), nil +} + +// mcpEnvelope reproduces the real server response shape the executor hands back: +// Response{"content":{"errorCode","errorMsg","success","result":{...}}}. Tests +// must use this (not a flat map) or they would miss the nested-unwrap bug. +func mcpEnvelope(result map[string]any) map[string]any { + return map[string]any{ + "endpoint": "https://mcp-gw.dingtalk.com/server/op-app", + "content": map[string]any{ + "errorCode": nil, + "errorMsg": nil, + "success": true, + "result": result, + }, + } +} + +// instantSleep replaces the poll-wait with a no-op so tests run without delay. +func instantSleep(t *testing.T) { + t.Helper() + prev := robotCreateSleepFn + robotCreateSleepFn = func(context.Context, time.Duration) error { return nil } + t.Cleanup(func() { robotCreateSleepFn = prev }) +} + +func runBotCreate(t *testing.T, runner executor.Runner, args ...string) (string, error) { + t.Helper() + cmd := newChatBotCreateCommand(runner) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs(args) + err := cmd.Execute() + return out.String(), err +} + +func TestBotCreatePollsUntilSuccess(t *testing.T) { + instantSleep(t) + + runner := &scriptedRunner{fn: func(call int, inv executor.Invocation) executor.Result { + switch call { + case 0: // submit + return executor.Result{Invocation: inv, Response: mcpEnvelope(map[string]any{ + "taskId": "T1", "status": "WAITING", + "interval": float64(1), "expiresIn": float64(60), + })} + case 1: // first poll → still waiting + return executor.Result{Invocation: inv, Response: mcpEnvelope(map[string]any{"status": "WAITING"})} + default: // second poll → success + return executor.Result{Invocation: inv, Response: mcpEnvelope(map[string]any{ + "status": "SUCCESS", "agentId": "ag1", "robotCode": "rc1", + "clientId": "ci1", "clientSecret": "cs1", + })} + } + }} + + out, err := runBotCreate(t, runner, "--app-name", "a", "--robot-name", "b", "--desc", "c") + if err != nil { + t.Fatalf("Execute() error = %v\n%s", err, out) + } + // First call must be the async submit, routed to opendev. + if got := runner.calls[0].Tool; got != robotCreateSubmitTool { + t.Fatalf("first tool = %q, want %q", got, robotCreateSubmitTool) + } + if got := runner.calls[0].CanonicalProduct; got != "opendev" { + t.Fatalf("product = %q, want opendev", got) + } + // Subsequent calls poll query with the submitted taskId. + if got := runner.calls[1].Tool; got != robotCreateQueryTool { + t.Fatalf("poll tool = %q, want %q", got, robotCreateQueryTool) + } + if got := runner.calls[1].Params["taskId"]; got != "T1" { + t.Fatalf("poll taskId = %#v, want T1", got) + } + if len(runner.calls) != 3 { + t.Fatalf("calls = %d (submit + 2 polls expected)", len(runner.calls)) + } + for _, want := range []string{"SUCCESS", "agentId", "robotCode", "clientId", "clientSecret", "cs1"} { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q:\n%s", want, out) + } + } +} + +func TestBotCreateFailSurfacesTaskID(t *testing.T) { + instantSleep(t) + + runner := &scriptedRunner{fn: func(call int, inv executor.Invocation) executor.Result { + if call == 0 { + return executor.Result{Invocation: inv, Response: mcpEnvelope(map[string]any{ + "taskId": "T-FAIL", "interval": float64(1), "expiresIn": float64(60), + })} + } + return executor.Result{Invocation: inv, Response: mcpEnvelope(map[string]any{"status": "FAIL"})} + }} + + _, err := runBotCreate(t, runner, "--app-name", "a", "--robot-name", "b", "--desc", "c") + if err == nil { + t.Fatal("expected error on FAIL, got nil") + } + if !strings.Contains(err.Error(), "T-FAIL") || !strings.Contains(err.Error(), "--task-id") { + t.Fatalf("error should carry taskId + retry hint, got: %v", err) + } +} + +func TestBotCreateDeadlineSurfacesTaskID(t *testing.T) { + instantSleep(t) + + runner := &scriptedRunner{fn: func(call int, inv executor.Invocation) executor.Result { + if call == 0 { + return executor.Result{Invocation: inv, Response: mcpEnvelope(map[string]any{ + "taskId": "T-SLOW", "interval": float64(2), "expiresIn": float64(5), + })} + } + return executor.Result{Invocation: inv, Response: mcpEnvelope(map[string]any{"status": "WAITING"})} + }} + + _, err := runBotCreate(t, runner, "--app-name", "a", "--robot-name", "b", "--desc", "c") + if err == nil { + t.Fatal("expected deadline error, got nil") + } + if !strings.Contains(err.Error(), "T-SLOW") { + t.Fatalf("deadline error should carry taskId, got: %v", err) + } +} + +func TestBotCreatePassesTaskIDOnRetry(t *testing.T) { + instantSleep(t) + + runner := &scriptedRunner{fn: func(call int, inv executor.Invocation) executor.Result { + if call == 0 { + return executor.Result{Invocation: inv, Response: mcpEnvelope(map[string]any{ + "status": "SUCCESS", "agentId": "ag", "robotCode": "rc", + })} + } + return executor.Result{Invocation: inv} + }} + + _, err := runBotCreate(t, runner, "--app-name", "a", "--robot-name", "b", "--desc", "c", "--task-id", "PRIOR-1") + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if got := runner.calls[0].Params["taskId"]; got != "PRIOR-1" { + t.Fatalf("submit taskId = %#v, want PRIOR-1", got) + } +} diff --git a/internal/helpers/connect.go b/internal/helpers/connect.go new file mode 100644 index 00000000..3cbb483a --- /dev/null +++ b/internal/helpers/connect.go @@ -0,0 +1,338 @@ +// 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" + "time" + + 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" +) + +// connect 是顶层的「渠道感知建联」命令(不挂在 chat 下)。一句 +// `dws connect` 完成三件事:① 探测当前 agent 跑在哪个渠道;② 按需建号 +// (复用 chat bot create 的服务端异步 provisioning);③ 输出该渠道对应的 +// 建联方案(接到本地 agent 的具体方式)。 +// +// 渠道路由表: +// - openclaw → dingtalk-openclaw-connector(plugin-sdk 契约 / OpenAI-compatible endpoint) +// - qoder / qoderwork → stream 桥接到本地 agent CLI(qodercli -p) +// - hermes → 官方 channel 渠道 +func init() { + RegisterPublic(func() Handler { + return connectHandler{} + }) +} + +type connectHandler struct{} + +func (connectHandler) Name() string { + return "connect" +} + +func (connectHandler) Command(runner executor.Runner) *cobra.Command { + return newConnectCommand(runner) +} + +// 支持的渠道。 +var connectChannels = map[string]struct{}{ + "openclaw": {}, + "qoder": {}, + "qoderwork": {}, + "hermes": {}, + "workbuddy": {}, +} + +// resolveConnectChannel 按「显式优先 + 信号兜底」解析当前 agent 渠道。 +// 优先级:--channel 显式参数 > DWS_AGENT_CHANNEL 环境变量 > 各 agent 已知 +// 运行时信号。返回渠道名与判定依据(detectedBy,便于排查)。 +// +// 信号说明(实测): +// - openclaw 连接器运行时注入 DINGTALK_AGENT=DING_DWS_CLAW。 +// - qoder / qoderwork 的 qodercli 子进程注入 QODER_CLI=1(两者同值, +// 无法仅凭此区分),故 qoder 与 qoderwork 的细分必须靠 DWS_AGENT_CHANNEL +// 显式指定,信号兜底时统一归为 qoder 家族。 +// - hermes 走官方 channel,约定以 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" + } + // 信号兜底。 + 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" + } + if strings.TrimSpace(os.Getenv("WORKBUDDY")) != "" || strings.TrimSpace(os.Getenv("CODEBUDDY_CLI")) != "" { + return "workbuddy", "signal:WORKBUDDY" + } + if strings.TrimSpace(os.Getenv("QODER_CLI")) != "" { + // qoder / qoderwork 共用 QODER_CLI=1,无法仅凭信号细分,统一归 qoder; + // 要接 qoderwork 请显式 --channel qoderwork 或设 DWS_AGENT_CHANNEL=qoderwork。 + return "qoder", "signal:QODER_CLI" + } + return "", "undetected" +} + +// buildConnectPlan 返回某渠道把机器人接到本地 agent 的建联方案。 +func buildConnectPlan(channel, clientID, robotCode string) map[string]any { + switch channel { + case "openclaw": + return map[string]any{ + "method": "openclaw-connector", + "summary": "通过 dingtalk-openclaw-connector 接入(plugin-sdk 契约 / OpenAI-compatible endpoint)", + "steps": []string{ + "将 clientId/clientSecret 写入 openclaw.json 的 channels.dingtalk-connector", + "openclaw gateway restart", + "参考 https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector", + }, + } + case "qoder", "qoderwork": + return map[string]any{ + "method": "stream-bridge", + "summary": fmt.Sprintf("用 clientId/clientSecret 起 Stream 长连接,订阅 TOPIC_ROBOT,转发到本地 %s agent CLI(qodercli -p)", channel), + "steps": []string{ + "用 clientId/clientSecret 起 DWClient/Stream,注册 TOPIC_ROBOT 回调", + "收到消息 → 调 qodercli -p \"\" -f text → stdout 作为回复", + "经 sessionWebhook 把回复发回钉钉", + }, + } + case "hermes": + return map[string]any{ + "method": "official-channel", + "summary": "通过钉钉官方 channel 渠道建联(hermes agent)", + "steps": []string{ + "用 clientId/clientSecret 走官方 channel 订阅机器人消息", + "将消息路由到 hermes agent 处理后回复", + }, + } + case "workbuddy": + return map[string]any{ + "method": "stream-bridge", + "summary": "一键背后建号(dws chat bot create) + Stream(WebSocket)建联,订阅 TOPIC_ROBOT,转发到 WorkBuddy 助理处理后回复(全程程序化,无需 WorkBuddy UI 手填凭证)", + "steps": []string{ + "dws connect 已用服务端 API 程序化建号拿 clientId/clientSecret(一键背后创建,不走 WorkBuddy 助理设置 UI)", + "用 clientId/clientSecret 起 Stream,注册 TOPIC_ROBOT 回调(无公网 IP 即可;如需 HTTP 回调模式则另配 AES Key/Token)", + "收到消息 → 转发到 WorkBuddy 助理(接收端/API)→ 回复经 sessionWebhook 发回钉钉", + "钉钉应用需开通权限:Card.Streaming.Write、Card.Instance.Write、qyapi_robot_sendmsg(参考 https://www.codebuddy.cn/docs/workbuddy/Dingtalk-Guide)", + }, + } + default: + return map[string]any{"method": "unknown"} + } +} + +// connectStartCommand 返回 --start 时该渠道实际拉起的连接器命令(argv)。 +// 解析优先级:环境变量 DWS_CONNECT_CMD(空格分隔,便于自定义/测试)> 各渠道 +// 内置默认。返回 nil 表示该渠道无内置启动命令,需用 DWS_CONNECT_CMD 指定。 +// 纯函数,无副作用,便于单测。 +func connectStartCommand(channel string) []string { + if v := strings.TrimSpace(os.Getenv("DWS_CONNECT_CMD")); v != "" { + return strings.Fields(v) + } + switch channel { + case "qoder", "qoderwork", "workbuddy": + // 仓库自带的 Stream 建联桥接(订阅 TOPIC_ROBOT,收→回,验证建联闭环)。 + // 三者都走程序化 Stream 建联,--start 一键拉起,无需任何 UI 手动配置。 + return []string{"node", "bot_stream_probe.js"} + case "openclaw": + return []string{"openclaw", "gateway", "restart"} + default: + // hermes 等暂无内置启动命令,需 DWS_CONNECT_CMD 指定。 + return nil + } +} + +// connectProvision 复用 chat bot create 的服务端异步建号流程(submit + poll), +// 但把终态 payload 返回给调用方(而非直接写出),供 connect 取 clientId / +// clientSecret / robotCode 后继续路由。FAIL/EXPIRED 时返回带 taskId 的错误, +// 调用方可用 --task-id 幂等重试。 +func connectProvision(cmd *cobra.Command, runner executor.Runner, params map[string]any) (map[string]any, error) { + submitRes, err := runRobotCreateTool(runner, cmd, robotCreateSubmitTool, params, false) + if err != nil { + return nil, err + } + submitPayload := robotCreatePayload(submitRes.Response) + taskID := robotResultString(submitPayload, "taskId") + if taskID == "" { + // 服务端直接返回终态(无 taskId),原样返回。 + return submitPayload, nil + } + + interval := robotResultDuration(submitPayload, "interval", robotCreateDefaultInterval) + if interval < robotCreatePollMinInterval { + interval = robotCreatePollMinInterval + } + if interval > robotCreatePollMaxInterval { + interval = robotCreatePollMaxInterval + } + deadline := robotResultDuration(submitPayload, "expiresIn", robotCreateDefaultDeadline) + + elapsed := time.Duration(0) + queryParams := map[string]any{"taskId": taskID} + for { + if err := robotCreateSleepFn(cmd.Context(), interval); err != nil { + return nil, err + } + elapsed += interval + + queryRes, err := runRobotCreateTool(runner, cmd, robotCreateQueryTool, queryParams, false) + if err != nil { + return nil, err + } + queryPayload := robotCreatePayload(queryRes.Response) + switch strings.ToUpper(robotResultString(queryPayload, "status")) { + case "SUCCESS", "APPROVAL_REQUIRED": + return queryPayload, nil + case "FAIL", "EXPIRED": + status := strings.ToUpper(robotResultString(queryPayload, "status")) + return nil, apperrors.NewInternal(fmt.Sprintf( + "robot creation %s (taskId=%s); retry with: dws connect ... --task-id %s", + status, taskID, taskID)) + case "WAITING", "": + // 继续轮询。 + default: + return queryPayload, nil + } + + if elapsed >= deadline { + return nil, apperrors.NewInternal(fmt.Sprintf( + "robot creation still WAITING after %s (taskId=%s); retry with: dws connect ... --task-id %s", + deadline, taskID, taskID)) + } + } +} + +func newConnectCommand(runner executor.Runner) *cobra.Command { + cmd := &cobra.Command{ + Use: "connect", + Short: "渠道感知建联:探测 agent 渠道 → 建号 → 按渠道把机器人接到本地 agent", + Long: "一句话把钉钉机器人和当前本地 agent 建联。\n" + + "① 探测渠道(--channel 显式 > DWS_AGENT_CHANNEL > 运行时信号兜底);\n" + + "② 建号(缺凭证时复用服务端异步 provisioning,返回 clientId/clientSecret/robotCode,clientSecret 仅一次);\n" + + "③ 输出该渠道的建联方案:openclaw→连接器 / qoder|qoderwork→Stream 桥接到 qodercli / hermes→官方 channel / workbuddy→WorkBuddy 钉钉集成。\n" + + "已有机器人用 --client-id/--client-secret 直接建联;新建机器人传 --app-name/--robot-name/--desc。", + Example: " dws connect --channel auto --app-name \"销售助手\" --robot-name \"销售助手机器人\" --desc \"销售线索查询\"\n" + + " dws connect --channel qoderwork --client-id --client-secret ", + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + channelFlag, _ := cmd.Flags().GetString("channel") + channel, detectedBy := resolveConnectChannel(channelFlag) + if channel == "" { + return apperrors.NewValidation("无法探测 agent 渠道;请用 --channel 指定 (openclaw|qoder|qoderwork|hermes|workbuddy) 或设置 DWS_AGENT_CHANNEL") + } + if _, ok := connectChannels[channel]; !ok { + return apperrors.NewValidation(fmt.Sprintf("未知渠道 %q(支持 openclaw|qoder|qoderwork|hermes|workbuddy)", channel)) + } + + clientID, _ := cmd.Flags().GetString("client-id") + clientSecret, _ := cmd.Flags().GetString("client-secret") + robotCode, _ := cmd.Flags().GetString("robot-code") + var status string + provisioned := false + + if strings.TrimSpace(clientID) == "" || strings.TrimSpace(clientSecret) == "" { + appName, _ := cmd.Flags().GetString("app-name") + robotName, _ := cmd.Flags().GetString("robot-name") + desc, _ := cmd.Flags().GetString("desc") + if strings.TrimSpace(appName) == "" || strings.TrimSpace(robotName) == "" || strings.TrimSpace(desc) == "" { + return apperrors.NewValidation("需要 --client-id/--client-secret(用现成机器人),或 --app-name/--robot-name/--desc(新建机器人)") + } + params := map[string]any{"appName": appName, "robotName": robotName, "desc": desc} + if v, _ := cmd.Flags().GetString("task-id"); strings.TrimSpace(v) != "" { + params["taskId"] = strings.TrimSpace(v) + } + if commandDryRun(cmd) { + return writeCommandPayload(cmd, map[string]any{ + "channel": channel, "detectedBy": detectedBy, "dryRun": true, + "wouldProvision": params, "connect": buildConnectPlan(channel, "", ""), + }) + } + payload, err := connectProvision(cmd, runner, params) + if err != nil { + return err + } + clientID = robotResultString(payload, "clientId") + clientSecret = robotResultString(payload, "clientSecret") + robotCode = robotResultString(payload, "robotCode") + status = robotResultString(payload, "status") + provisioned = true + } + + out := map[string]any{ + "channel": channel, + "detectedBy": detectedBy, + "provisioned": provisioned, + "clientId": clientID, + "robotCode": robotCode, + "connect": buildConnectPlan(channel, clientID, robotCode), + } + if status != "" { + out["status"] = status + if strings.EqualFold(status, "APPROVAL_REQUIRED") { + out["approvalNotice"] = "应用需企业管理员后台审批通过后,钉钉才会把消息路由进来" + } + } + if provisioned { + out["clientSecret"] = clientSecret + out["clientSecretNotice"] = "clientSecret 仅返回一次,请立即安全保存" + } + + // --start:实际拉起该渠道的连接器(os/exec),把 clientId/secret 注入 + // 子进程环境(CID/SEC,对齐 bot_stream_probe.js)。前台运行直到中断。 + if start, _ := cmd.Flags().GetBool("start"); start { + argv := connectStartCommand(channel) + if len(argv) == 0 { + return apperrors.NewValidation(fmt.Sprintf("渠道 %q 暂无内置启动命令;用环境变量 DWS_CONNECT_CMD 指定要运行的连接器", channel)) + } + 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() + } + + return writeCommandPayload(cmd, out) + }, + } + preferLegacyLeaf(cmd) + cmd.Flags().String("channel", "auto", "渠道:auto(默认,自动探测)|openclaw|qoder|qoderwork|hermes|workbuddy") + cmd.Flags().String("app-name", "", "新建机器人:智能体应用名称,2~20 字,企业内唯一") + cmd.Flags().String("robot-name", "", "新建机器人:承载机器人名称,2~20 字") + cmd.Flags().String("desc", "", "新建机器人:功能描述,≤200 字") + cmd.Flags().String("task-id", "", "建号重试用:上次返回的 taskId,避免重复建号") + cmd.Flags().String("client-id", "", "用现成机器人建联:clientId(AppKey)") + cmd.Flags().String("client-secret", "", "用现成机器人建联:clientSecret(AppSecret)") + cmd.Flags().String("robot-code", "", "用现成机器人建联:robotCode(可选)") + cmd.Flags().Bool("start", false, "建联后实际拉起该渠道的连接器(前台运行;可用 DWS_CONNECT_CMD 覆盖启动命令)") + return cmd +} diff --git a/internal/helpers/connect_test.go b/internal/helpers/connect_test.go new file mode 100644 index 00000000..362cd40f --- /dev/null +++ b/internal/helpers/connect_test.go @@ -0,0 +1,129 @@ +// 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 "testing" + +// clearChannelEnv 把所有参与渠道探测的环境变量清空(设为空=按未设处理), +// 避免测试机自身的 QODER_CLI 等信号干扰用例。t.Setenv 会在用例结束自动还原。 +func clearChannelEnv(t *testing.T) { + for _, k := range []string{ + "DWS_AGENT_CHANNEL", "DINGTALK_AGENT", "OPENCLAW", "OPENCLAW_GATEWAY", + "HERMES_AGENT", "HERMES", "QODER_CLI", "DWS_CONNECT_CMD", + "WORKBUDDY", "CODEBUDDY_CLI", + } { + t.Setenv(k, "") + } +} + +func TestResolveConnectChannel(t *testing.T) { + cases := []struct { + name string + flag string + env map[string]string + wantChannel string + wantDetectedBy string + }{ + {"显式 flag 最高优先", "openclaw", map[string]string{"DWS_AGENT_CHANNEL": "qoder", "QODER_CLI": "1"}, "openclaw", "flag:--channel"}, + {"env 压过信号", "auto", map[string]string{"DWS_AGENT_CHANNEL": "qoderwork", "QODER_CLI": "1"}, "qoderwork", "env:DWS_AGENT_CHANNEL"}, + {"信号 openclaw(DINGTALK_AGENT)", "auto", map[string]string{"DINGTALK_AGENT": "DING_DWS_CLAW"}, "openclaw", "signal:DINGTALK_AGENT"}, + {"信号 openclaw(OPENCLAW)", "", map[string]string{"OPENCLAW": "1"}, "openclaw", "signal:OPENCLAW"}, + {"信号 qoder 家族", "", map[string]string{"QODER_CLI": "1"}, "qoder", "signal:QODER_CLI"}, + {"信号 hermes", "auto", map[string]string{"HERMES_AGENT": "1"}, "hermes", "signal:HERMES"}, + {"信号 workbuddy", "auto", map[string]string{"WORKBUDDY": "1"}, "workbuddy", "signal:WORKBUDDY"}, + {"探测不到", "auto", nil, "", "undetected"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + clearChannelEnv(t) + for k, v := range tc.env { + t.Setenv(k, v) + } + ch, by := resolveConnectChannel(tc.flag) + if ch != tc.wantChannel || by != tc.wantDetectedBy { + t.Fatalf("resolveConnectChannel(%q) = (%q,%q), want (%q,%q)", tc.flag, ch, by, tc.wantChannel, tc.wantDetectedBy) + } + }) + } +} + +func TestConnectChannelsKnown(t *testing.T) { + for _, ch := range []string{"openclaw", "qoder", "qoderwork", "hermes", "workbuddy"} { + if _, ok := connectChannels[ch]; !ok { + t.Errorf("渠道 %q 应在 connectChannels 中", ch) + } + } + if _, ok := connectChannels["weird"]; ok { + t.Error("未知渠道不应在 connectChannels 中") + } +} + +func TestBuildConnectPlanMethod(t *testing.T) { + want := map[string]string{ + "openclaw": "openclaw-connector", + "qoder": "stream-bridge", + "qoderwork": "stream-bridge", + "hermes": "official-channel", + "workbuddy": "stream-bridge", + "weird": "unknown", + } + for ch, m := range want { + got, _ := buildConnectPlan(ch, "cid", "rc")["method"].(string) + if got != m { + t.Errorf("buildConnectPlan(%q).method = %q, want %q", ch, got, m) + } + } +} + +func TestConnectStartCommand(t *testing.T) { + t.Run("DWS_CONNECT_CMD 覆盖", func(t *testing.T) { + clearChannelEnv(t) + t.Setenv("DWS_CONNECT_CMD", "my-bridge --flag x") + got := connectStartCommand("qoder") + want := []string{"my-bridge", "--flag", "x"} + if !equalStringSlice(got, want) { + t.Fatalf("got %v, want %v", got, want) + } + }) + t.Run("各渠道默认", func(t *testing.T) { + clearChannelEnv(t) + if got := connectStartCommand("qoder"); len(got) == 0 || got[0] != "node" { + t.Errorf("qoder 默认应为 node ...,got %v", got) + } + if got := connectStartCommand("qoderwork"); len(got) == 0 || got[0] != "node" { + t.Errorf("qoderwork 默认应为 node ...,got %v", got) + } + if got := connectStartCommand("workbuddy"); len(got) == 0 || got[0] != "node" { + t.Errorf("workbuddy 默认应为 node ...(一键起 Stream 桥接),got %v", got) + } + if got := connectStartCommand("openclaw"); len(got) == 0 || got[0] != "openclaw" { + t.Errorf("openclaw 默认应为 openclaw ...,got %v", got) + } + if got := connectStartCommand("hermes"); got != nil { + t.Errorf("hermes 无内置启动命令应返回 nil,got %v", got) + } + }) +} + +func equalStringSlice(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/transport/client.go b/internal/transport/client.go index e22b90f2..37b5bc72 100644 --- a/internal/transport/client.go +++ b/internal/transport/client.go @@ -494,8 +494,17 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque } func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte) (*http.Response, error) { - // Strip any query/fragment from the endpoint to prevent parameter injection. - endpoint = validate.StripQueryFragment(endpoint) + // MCP marketplace "self-contained" URLs carry a pre-authed ?key=<...> that IS + // the credential (bound at issue time to the user+org). For those, preserve + // the query verbatim and do NOT attach the session bearer token: the gateway + // authenticates by the key, and a conflicting bearer makes it resolve the MCP + // via the caller's discovery context instead, failing with "MCP不存在". For + // every other endpoint, strip the query/fragment to prevent parameter + // injection (the bearer token is the auth). + keyAuthed := endpointHasPreAuthKey(endpoint) && c.isEndpointTrusted(endpoint) + if !keyAuthed { + endpoint = validate.StripQueryFragment(endpoint) + } var lastErr error for attempt := 0; attempt <= c.MaxRetries; attempt++ { req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) @@ -518,10 +527,12 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte) if c.ExecutionId != "" { req.Header.Set(HeaderExecutionId, c.ExecutionId) } - if token := sanitizeBearerToken(c.AuthToken); token != "" { - if c.isEndpointTrusted(endpoint) { - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("x-user-access-token", token) + if !keyAuthed { + if token := sanitizeBearerToken(c.AuthToken); token != "" { + if c.isEndpointTrusted(endpoint) { + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("x-user-access-token", token) + } } } for key, value := range c.ExtraHeaders { @@ -719,6 +730,19 @@ func parseRetryAfter(raw string) (time.Duration, bool) { return delay, true } +// endpointHasPreAuthKey reports whether the endpoint carries a pre-authed +// ?key=<...> query parameter, as issued by the DingTalk MCP marketplace for +// self-contained (user+org-bound) server URLs. Such keys are themselves the +// credential and must be forwarded verbatim; the session bearer token is +// omitted for them (see doWithRetry). +func endpointHasPreAuthKey(endpoint string) bool { + parsed, err := url.Parse(endpoint) + if err != nil { + return false + } + return strings.TrimSpace(parsed.Query().Get("key")) != "" +} + // isEndpointTrusted checks whether the endpoint is HTTPS and belongs to a // trusted domain. When no auth token is set this is a no-op. // Set DWS_ALLOW_HTTP_ENDPOINTS=1 for development/testing to allow HTTP, but diff --git a/internal/transport/client_test.go b/internal/transport/client_test.go index 27d78c65..154f6c48 100644 --- a/internal/transport/client_test.go +++ b/internal/transport/client_test.go @@ -330,6 +330,46 @@ func TestCallToolInjectsAuthHeaders(t *testing.T) { } } +// TestCallToolKeyURLKeepsQueryAndOmitsBearer verifies that for a self-contained +// MCP marketplace URL carrying a pre-authed ?key=<...>, the transport forwards +// the key verbatim (query preserved) and does NOT attach the session bearer +// token — the key is the credential. A conflicting bearer makes the gateway +// resolve the MCP via the caller's discovery context and fail ("MCP不存在"). +func TestCallToolKeyURLKeepsQueryAndOmitsBearer(t *testing.T) { + t.Parallel() + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("key"); got != "k-secret-123" { + t.Fatalf("key query = %q, want k-secret-123 (query must be preserved)", got) + } + if got := r.Header.Get("Authorization"); got != "" { + t.Fatalf("Authorization header = %q, want empty (bearer must be omitted for key URLs)", got) + } + if got := r.Header.Get("x-user-access-token"); got != "" { + t.Fatalf("x-user-access-token = %q, want empty (omitted for key URLs)", got) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": 3, + "result": map[string]any{"content": map[string]any{"ok": true}}, + }) + })) + defer server.Close() + + client := NewClient(server.Client()) + client.AuthToken = "test-token" // present but must be skipped for key URLs + client.TrustedDomains = []string{"127.0.0.1"} + + endpoint := server.URL + "?key=k-secret-123" + result, err := client.CallTool(context.Background(), endpoint, "create_dingtalk_robot", map[string]any{"appName": "x"}) + if err != nil { + t.Fatalf("CallTool() error = %v", err) + } + if result.Content["ok"] != true { + t.Fatalf("CallTool() content = %#v, want ok=true", result.Content) + } +} + func TestCallToolAcceptsStructuredContentResults(t *testing.T) { t.Parallel() diff --git a/skills/mono/references/best_practices/01-messaging.md b/skills/mono/references/best_practices/01-messaging.md index fb95e817..f826ef42 100644 --- a/skills/mono/references/best_practices/01-messaging.md +++ b/skills/mono/references/best_practices/01-messaging.md @@ -4,6 +4,7 @@ | Recipe | 行动指南(固定路线) | |--------|-------------------| +| provision-bot | **首选顶层命令 `dws connect`(渠道感知一键建联,不在 chat 下)**:一句话完成「探测渠道→按需建号→按渠道建联」。渠道探测=**显式优先+信号兜底**:`--channel` 显式 > `DWS_AGENT_CHANNEL` 环境变量 > 运行时信号(`DINGTALK_AGENT=DING_DWS_CLAW`→openclaw;`QODER_CLI=1`→qoder 家族,qoder/qoderwork 细分须靠 `DWS_AGENT_CHANNEL`;`HERMES_AGENT`→hermes)。
**渠道路由表**:`openclaw`→dingtalk-openclaw-connector(plugin-sdk 契约 / OpenAI-compatible endpoint);`qoder`/`qoderwork`→Stream 桥接到本地 agent CLI(`qodercli -p "" -f text`);`hermes`→官方 channel;`workbuddy`→WorkBuddy(CodeBuddy):程序化建号 + Stream 建联**一键背后接入**(`--start` 自动拉起转发,无需在 WorkBuddy 助理设置 UI 手填凭证;需开 Card.Streaming.Write/Card.Instance.Write/qyapi_robot_sendmsg)。
新建机器人:`dws connect --channel auto --app-name <名> --robot-name <名> --desc <描述>`;已有机器人直接建联:`dws connect --channel qoderwork --client-id --client-secret `。
底层三段(connect 内部按需复用,也可手动单跑):① **建号** `dws chat bot create --app-name <名> --robot-name <名> --desc <描述>`(MCP create_dingtalk_robot,一次拿 agentId/robotCode/clientId/clientSecret,secret 仅返回一次;服务端异步偶发 FAIL,用 `--task-id ` 幂等重试,勿 grep 过滤输出以免丢 secret)
② **Stream 建联**:拿 clientId/secret 起 DWClient 订阅 TOPIC_ROBOT,agent 收→回
③ **审批+验证**:`qyapi_robot_sendmsg` scope 审批(建号≠可用,`status=APPROVAL_REQUIRED` 时 WSS 能连但消息不路由,connected 不是能力回执,真实发消息探测才算数)+ `chat bot search` 交叉验证 | | query-group-chat | **优先**:`chat_export_messages.py`(开源版未引入;可手动用 `dws chat message list` 翻页后写入文件)(自动搜群+翻页+导出)
备选:1. `chat search --query "<群名>"` → 取 `openConversationId`
2. `chat message list --group --time ""` → 取消息列表
3. **翻页**:`hasMore=true` 时取本页最后 `createTime` 作为下次 `--time`,重复至 `hasMore=false`
4. `--forward=false` 拉给定时间**之前**的消息
5. 合并全部消息后总结 | | query-private-chat | **优先**:`chat_history_with_user.py`(开源版未引入;可手动用 `dws chat search` + `dws chat message list` 组合)(自动搜人+翻页+导出)
备选:1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`
2. `chat message list --user --time ""` → 取消息列表
3. **翻页**:同 query-group-chat
4. 合并全部消息后总结 | | escalate-ding | 三级升级:
1. `ding message send --robot-code --type app --users --content "<内容>"`(必填项见 [ding.md](../products/ding.md))
2. `chat message send --group --text "<内容>"` 群里提醒(可选 `--title` / `@` 见 [chat.md](../products/chat.md))
3. `todo task create --title "<标题>" --executors --priority 40` 建紧急待办
前置:`aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`;`chat search --query "<群名>"` → 取 `openConversationId` | diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md index f925b042..2db01586 100644 --- a/skills/mono/references/products/chat.md +++ b/skills/mono/references/products/chat.md @@ -1099,6 +1099,79 @@ search 与 find 选择指南: | 额外返回 openDingTalkId | 无 | 有(可用于给机器人发单聊消息) | | 触发词 | "我创建的""我的""我自己的" | "搜索机器人""找机器人""查机器人" | +#### 创建【新】机器人 + 建联(建号 → Stream 建联 → 审批开通) + +范围: `dws chat bot` 只能**搜索**(search / find)和**拉机器人进群**(group members add-bot),**不能创建**机器人。要"建一个新机器人 / 给 agent 接入钉钉",整条链路分三段:**① 建号(拿凭据)→ ② Stream 建联(用凭据起连接)→ ③ 审批开通**,缺一段都不算可用。 + +典型触发词: "给我建个机器人""创建一个机器人""给 agent 接个钉钉机器人""接入 OpenClaw / Hermes""provision a bot"。 + +##### ① 建号:`dws chat bot create` 一次拿到 robotCode + clientId + clientSecret + +`dws chat bot create` 调用服务端 MCP 工具 `create_dingtalk_robot`,一次性创建企业自建 Agent 应用及承载机器人,返回 `agentId` / `robotCode` / `clientId` / `clientSecret`(**clientSecret 仅返回一次,立即安全保存**)。无需扫码、可无人值守;`corpId` / `userid` 由 MCP 服务端按当前登录身份注入。 + +``` +dws chat bot create \ + --app-name "销售助手" \ # 智能体应用名,2~20 字,企业内唯一(必填) + --robot-name "销售助手机器人" \ # 承载机器人名,2~20 字(必填) + --desc "销售线索查询与客户跟进" \ # 功能描述,≤200 字(必填) + --robot-media-id "@..." \ # 可选:机器人图标 mediaId,留空用服务端默认图标 + --preview-media-id "@..." # 可选:预览图 mediaId,留空复用 --robot-media-id +# 返回 agentId / robotCode / clientId / clientSecret(secret 仅此一次,务必立即保存) +``` + +> 实现说明:该命令的 MCP 端点在 dws 内**硬编码**(不走服务发现),当前指向「钉钉开放平台应用管理」MCP。它是 MCP 广场的自包含 `?key=` URL,dws transport 对带 key 的 URL 保留 query 并跳过会话 bearer(key 即凭据,详见 `internal/transport`、`internal/app/direct_runtime.go`)。 +> `--robot-media-id` / `--preview-media-id` 的 mediaId 需先经 `media.upload` 上传获得。 + +##### ② Stream 建联:让机器人收→回(无公网 IP / Webhook) + +建号本质只是拿到 `clientId` / `clientSecret`。**拿到这对凭据后,由一个 agent 运行时起 Stream 连接即可驱动收发,与凭据从哪来无关**(API 建号 / 手动从开放平台后台抄,建联这段都一样)。机制(参考实现:[dingtalk-openclaw-connector](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector)): + +1. `new DWClient({ clientId, clientSecret })`(`dingtalk-stream` SDK) +2. `client.registerCallbackListener(TOPIC_ROBOT, handler)` 订阅机器人消息 +3. `client.connect()` 起 WebSocket 直连钉钉网关(**无需公网 IP / Webhook**) +4. 回调里解析 `res.data`,拿 `sessionWebhook` / `conversationId` / `text.content` / `senderStaffId`,转给本地 agent +5. agent 产出回复 → 回 `data.sessionWebhook` + +运行时桥接两条**对等**路径,按宿主选(**不是只支持 OpenClaw**,`peerDependencies.openclaw` 标记为 optional): +- **OpenClaw 及其 fork(如 Hermes)**: 走 Connector 的 `openclaw/plugin-sdk` channel 契约,Stream 直连(上面 1–5 即其内部实现)。把 `clientId` / `clientSecret` 配进账号文件后,`openclaw channels status --deep | grep -i dingtalk` 看 running。 +- **其它 agent(Claude Code / Cursor / Codex / Qoder / opencode / Gemini / Windsurf / Cline / Kiro / Trae 等约 14 个 agent home)**: 通过宿主暴露的 **OpenAI Chat Completions endpoint** 桥接,Connector 把钉钉消息转给该端点再回流;与 DEAP 架构同源。 + +> 账号文件字段、启动、多机器人 `chatbotUserId` 协作等具体配置,以 connector 仓 README / `docs/DINGTALK_MANUAL_SETUP.md` 为准,本 skill 不复制其实现细节。 + +**建联自检(不依赖完整 agent 运行时)**:用 `scripts/bot_stream_probe.js` 最小验证「建联 + 收→回」——起 DWClient 连接、订阅 `TOPIC_ROBOT`、收到 @机器人 消息后经 `sessionWebhook` 回 echo: + +``` +npm i dingtalk-stream +CID=<新clientId> SEC=<新secret> node scripts/bot_stream_probe.js +# 看到 "connect success" = 建联通过;再在钉钉把机器人拉进群 @它 一句, +# 看到 ">>> 收到消息" = 收通过;">>> 回复结果: 200" = 回通过(回复需先过 ③ 的 scope 审批) +``` + +##### ③ 审批开通 + 交叉验证(建号 ≠ 可用,最易踩) + +建号成功 / 能换 `access_token` 只代表【应用凭据】生效;机器人真正收发还需 `qyapi_robot_sendmsg` 等 robot scope **审批开通**。`openclaw channels status` 显示 `connected` / Stream WS 连上**都不是** DingTalk 的能力回执,**不能当作"建联成功"**。唯一可靠判定是真实发消息探测: + +``` +# 用新应用凭据探测真实发消息能力(appKey/appSecret 即 clientId/clientSecret) +TOKEN=$(curl -s -X POST 'https://api.dingtalk.com/v1.0/oauth2/accessToken' \ + -H 'Content-Type: application/json' -d '{"appKey":"<新clientId>","appSecret":"<新secret>"}' | jq -r .accessToken) +curl -s -X POST 'https://api.dingtalk.com/v1.0/robot/groupMessages/send' \ + -H "x-acs-dingtalk-access-token: $TOKEN" -H 'Content-Type: application/json' \ + -d '{"robotCode":"<新clientId>","openConversationId":"__probe__","msgKey":"sampleText","msgParam":"{\"content\":\"probe\"}"}' +# → Forbidden.AccessDenied ...qyapi_robot_sendmsg = 审批未过:照报错里给的 +# open-dev.dingtalk.com/appscope/apply?content=%23qyapi_robot_sendmsg 开通后再用 +# → 参数类错误(非 AccessDenied) = scope 已通,机器人可收发 +``` + +身份交叉验证(appKey ≠ robotCode): `dws chat bot search --client-id <新clientId> --client-secret <新secret> --format json` 查它名下机器人,拿 robotName / robotCode。 + +注意: + - **链路前置**:建号已是原生命令 `dws chat bot create`(MCP 端点硬编码,dws 内可直接闭环);建联需一个 agent 运行时;两段都要 robot scope 审批。前置不满足时明确告知用户,不要伪造创建成功。 + - **建号 ≠ 可用(血泪闸门,最易踩)**:能换 `access_token` 只代表【应用凭据】生效,机器人收发还需 `qyapi_robot_sendmsg` 审批;`connected` 不是能力回执。唯一可靠判定 = 第 ③ 段真实能力探测。 + - **mediaId**:`robotMediaId` / `previewMediaId` 需先经 `media.upload` 上传获得;留空走服务端默认图标。 + - 创建成功后,发消息/拉群仍走上面的 `chat message send-by-bot` / `group members add-bot`,用 `dws chat bot search` 返回的 `robotCode`。 + - 安全: 机器人在你的授权范围内以你的身份行事,按个人助理对待,勿用于无人值守的生产部署。 + ### category (会话分组管理) #### 获取用户自定义会话分组 diff --git a/skills/mono/scripts/bot_stream_probe.js b/skills/mono/scripts/bot_stream_probe.js new file mode 100644 index 00000000..c3b7baed --- /dev/null +++ b/skills/mono/scripts/bot_stream_probe.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * bot_stream_probe.js — 钉钉机器人「收→回」最小验证脚本 + * + * 用 dingtalk-stream SDK 起 DWClient Stream 连接,订阅 TOPIC_ROBOT;收到 + * @机器人 的消息后:ack → 打印消息 → 通过 sessionWebhook 回一条 echo。 + * 用来验证 `dws chat bot create` 建出的机器人「建联 + 收→回」闭环, + * 不依赖完整 OpenClaw/Hermes 运行时(机制等同 connector 的 connection.ts)。 + * + * 用法: + * npm i dingtalk-stream + * CID= SEC= node bot_stream_probe.js + * 然后在钉钉里把该机器人拉进群并 @它 发一句话。 + * + * 退出码:0 正常运行/收到消息;1 连接失败;2 缺凭据。 + * + * 注意(建号 ≠ 可用):连接与收消息只需有效应用凭据;但「回复」需机器人已通过 + * qyapi_robot_sendmsg 审批,未通过时连接/收消息正常、回复会被钉钉拒绝。 + * 审批入口见钉钉报错里的 open-dev.dingtalk.com/appscope/apply?content=%23qyapi_robot_sendmsg + */ +const { DWClient, TOPIC_ROBOT } = require('dingtalk-stream'); + +const clientId = process.env.CID; +const clientSecret = process.env.SEC; +if (!clientId || !clientSecret) { + console.error('缺少凭据:请设置环境变量 CID= SEC='); + process.exit(2); +} + +const client = new DWClient({ clientId, clientSecret }); + +client.registerCallbackListener(TOPIC_ROBOT, async (res) => { + // 立即 ack,避免钉钉重复投递 + const messageId = res.headers && res.headers.messageId; + if (messageId) client.socketCallBackResponse(messageId, { success: true }); + + let data = {}; + try { data = JSON.parse(res.data); } catch (_) { /* ignore */ } + const text = ((data.text && data.text.content) || '').trim(); + console.log('>>> 收到消息:', { + sender: data.senderNick, + conversationType: data.conversationType, // 1=单聊 2=群聊 + text, + sessionWebhook: data.sessionWebhook ? '有' : '无', + }); + + // 通过 sessionWebhook 回一条 echo(最小「回」验证) + if (data.sessionWebhook) { + try { + const r = await fetch(data.sessionWebhook, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ msgtype: 'text', text: { content: `收到:${text || '(空)'}` } }), + }); + console.log('>>> 回复结果:', r.status, await r.text()); + } catch (e) { + console.error('>>> 回复失败:', (e && e.message) || e); + } + } +}); + +client.connect().then(() => { + console.log('Stream 已连接,订阅 TOPIC_ROBOT,等待 @机器人 消息… (Ctrl-C 退出)'); +}).catch((e) => { + console.error('连接失败:', (e && e.message) || e); + process.exit(1); +}); diff --git a/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md index 24ebf51a..689dd27f 100644 --- a/skills/multi/dingtalk-chat/SKILL.md +++ b/skills/multi/dingtalk-chat/SKILL.md @@ -1,6 +1,6 @@ --- name: dingtalk-chat -description: 钉钉群聊与消息。Use when 用户提到 发消息/单聊/群聊/建群/拉人进群/改群名/搜索群/群成员管理/@消息/撤回消息/机器人群发/Webhook通知/发图片或文件到群。Distinct from dingtalk-ding(紧急DING消息/短信/电话)、dingtalk-mail(邮件)、dingtalk-edu-group(班级群)。命令前缀:dws chat。 +description: 钉钉群聊与消息。Use when 用户提到 发消息/单聊/群聊/建群/拉人进群/改群名/搜索群/群成员管理/@消息/撤回消息/机器人群发/Webhook通知/发图片或文件到群/搜机器人/查我的机器人/创建机器人/给agent接入钉钉机器人/接入OpenClaw或Hermes/provision bot。Distinct from dingtalk-ding(紧急DING消息/短信/电话)、dingtalk-mail(邮件)、dingtalk-edu-group(班级群)。命令前缀:dws chat。 cli_version: ">=0.2.14" metadata: category: product @@ -35,6 +35,8 @@ metadata: | "用机器人发消息" | `dws chat message send-by-bot --robot-code --group --title "<标题>" --text "<内容>"` | | "Webhook 推一条" | `dws chat message send-by-webhook --token --title "<标题>" --text "<内容>"` | | "撤回机器人消息" | `dws chat message recall-by-bot --robot-code --group --keys `(只能撤回机器人发的;撤回普通用户消息开源 dws v1.0.30 暂不支持)| +| "搜机器人" / "查我创建的机器人" | `dws chat bot find --query "<关键词>"`(全部可用,带 openDingTalkId)/ `dws chat bot search`(仅我创建的)| +| "给我建个机器人" / "给 agent 接入钉钉" / "接入 OpenClaw/Hermes/Qoder/Claude Code" | 三段链路:① 建号 `dws chat bot create --app-name <名> --robot-name <名> --desc <描述>`(调 MCP 工具 create_dingtalk_robot,一次拿 agentId/robotCode/clientId/clientSecret,secret 仅返回一次)→ ② Stream 建联(拿 clientId/secret 起 DWClient,运行时两条对等路径:plugin-sdk 契约 / OpenAI-compatible endpoint)→ ③ `qyapi_robot_sendmsg` 审批 + `dws chat bot search` 交叉验证。固定路线见 [chat.md](references/chat.md) 「创建【新】机器人 + 建联」节 | > **注**:v1.0.30 起 `chat message send / send-by-bot / send-by-webhook` 全部强制 `--title` 必填(单聊群聊都要)。 @@ -44,3 +46,4 @@ metadata: - 要发图片/文件 → 先 `dt_media_upload` 上传 → `python scripts/extract_media_id.py ""` 提取 mediaId → 再用 `--media-id` - 紧急升级(应用内/短信/电话)→ 切到 `dingtalk-ding` - 发邮件 → 切到 `dingtalk-mail` +- 要**新建**机器人 / 给 agent 接入钉钉 → 用原生命令 `dws chat bot create`(调 MCP 工具 create_dingtalk_robot,端点在 dws 内硬编码,拿 clientId/clientSecret),再由 agent 运行时起 Stream 建联(两条对等路径:plugin-sdk 契约 / OpenAI-compatible endpoint,参考 `@dingtalk-real-ai/dingtalk-connector`);见 [chat.md](references/chat.md) 「创建【新】机器人 + 建联」节,建完过 `qyapi_robot_sendmsg` 审批 + `dws chat bot search` 交叉验证 diff --git a/skills/multi/dingtalk-chat/references/01-messaging.md b/skills/multi/dingtalk-chat/references/01-messaging.md index 7932c5a8..2d72e2b6 100644 --- a/skills/multi/dingtalk-chat/references/01-messaging.md +++ b/skills/multi/dingtalk-chat/references/01-messaging.md @@ -6,6 +6,7 @@ | Recipe | 行动指南(固定路线) | |--------|-------------------| +| provision-bot | **首选顶层命令 `dws connect`(渠道感知一键建联,不在 chat 下)**:一句话完成「探测渠道→按需建号→按渠道建联」。渠道探测=**显式优先+信号兜底**:`--channel` 显式 > `DWS_AGENT_CHANNEL` 环境变量 > 运行时信号(`DINGTALK_AGENT=DING_DWS_CLAW`→openclaw;`QODER_CLI=1`→qoder 家族,qoder/qoderwork 细分须靠 `DWS_AGENT_CHANNEL`;`HERMES_AGENT`→hermes)。
**渠道路由表**:`openclaw`→dingtalk-openclaw-connector(plugin-sdk 契约 / OpenAI-compatible endpoint);`qoder`/`qoderwork`→Stream 桥接到本地 agent CLI(`qodercli -p "" -f text`);`hermes`→官方 channel;`workbuddy`→WorkBuddy(CodeBuddy):程序化建号 + Stream 建联**一键背后接入**(`--start` 自动拉起转发,无需在 WorkBuddy 助理设置 UI 手填凭证;需开 Card.Streaming.Write/Card.Instance.Write/qyapi_robot_sendmsg)。
新建机器人:`dws connect --channel auto --app-name <名> --robot-name <名> --desc <描述>`;已有机器人直接建联:`dws connect --channel qoderwork --client-id --client-secret `。
底层三段(connect 内部按需复用,也可手动单跑):① **建号** `dws chat bot create --app-name <名> --robot-name <名> --desc <描述>`(MCP create_dingtalk_robot,一次拿 agentId/robotCode/clientId/clientSecret,secret 仅返回一次;服务端异步偶发 FAIL,用 `--task-id ` 幂等重试,勿 grep 过滤输出以免丢 secret)
② **Stream 建联**:拿 clientId/secret 起 DWClient 订阅 TOPIC_ROBOT,agent 收→回
③ **审批+验证**:`qyapi_robot_sendmsg` scope 审批(建号≠可用,`status=APPROVAL_REQUIRED` 时 WSS 能连但消息不路由,connected 不是能力回执,真实发消息探测才算数)+ `chat bot search` 交叉验证 | | query-group-chat | **优先**:`python scripts/chat_export_messages.py --query "<群名>" --time "" [--no-forward] [--limit N] [--output messages.json]`(自动搜群+翻页+导出)
备选:1. `chat search --query "<群名>"` → 取 `openConversationId`
2. `chat message list --group --time ""` → 取消息列表
3. **翻页**:`hasMore=true` 时取本页最后 `createTime` 作为下次 `--time`,重复至 `hasMore=false`
4. `--forward=false` 拉给定时间**之前**的消息
5. 合并全部消息后总结 | | query-private-chat | **优先**:`python scripts/chat_history_with_user.py --name "<姓名>" --time "" [--no-forward] [--limit N] [--output messages.json]`(自动搜人+翻页+导出)
备选:1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`
2. `chat message list-direct --user --time ""` → 取消息列表
3. **翻页**:同 query-group-chat
4. 合并全部消息后总结 | | escalate-ding | 三级升级:
1. `ding message send --robot-code --type app --users --content "<内容>"`(必填项见 `dingtalk-ding/references/ding.md`)
2. `chat message send --group --text "<内容>"` 群里提醒(可选 `--title` / `@` 见 [chat.md](./chat.md))
3. `todo task create --title "<标题>" --executors --priority 40` 建紧急待办
前置:`aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`;`chat search --query "<群名>"` → 取 `openConversationId` | diff --git a/skills/multi/dingtalk-chat/references/chat.md b/skills/multi/dingtalk-chat/references/chat.md index 5640f510..e0677885 100644 --- a/skills/multi/dingtalk-chat/references/chat.md +++ b/skills/multi/dingtalk-chat/references/chat.md @@ -1099,6 +1099,79 @@ search 与 find 选择指南: | 额外返回 openDingTalkId | 无 | 有(可用于给机器人发单聊消息) | | 触发词 | "我创建的""我的""我自己的" | "搜索机器人""找机器人""查机器人" | +#### 创建【新】机器人 + 建联(建号 → Stream 建联 → 审批开通) + +范围: `dws chat bot` 只能**搜索**(search / find)和**拉机器人进群**(group members add-bot),**不能创建**机器人。要"建一个新机器人 / 给 agent 接入钉钉",整条链路分三段:**① 建号(拿凭据)→ ② Stream 建联(用凭据起连接)→ ③ 审批开通**,缺一段都不算可用。 + +典型触发词: "给我建个机器人""创建一个机器人""给 agent 接个钉钉机器人""接入 OpenClaw / Hermes""provision a bot"。 + +##### ① 建号:`dws chat bot create` 一次拿到 robotCode + clientId + clientSecret + +`dws chat bot create` 调用服务端 MCP 工具 `create_dingtalk_robot`,一次性创建企业自建 Agent 应用及承载机器人,返回 `agentId` / `robotCode` / `clientId` / `clientSecret`(**clientSecret 仅返回一次,立即安全保存**)。无需扫码、可无人值守;`corpId` / `userid` 由 MCP 服务端按当前登录身份注入。 + +``` +dws chat bot create \ + --app-name "销售助手" \ # 智能体应用名,2~20 字,企业内唯一(必填) + --robot-name "销售助手机器人" \ # 承载机器人名,2~20 字(必填) + --desc "销售线索查询与客户跟进" \ # 功能描述,≤200 字(必填) + --robot-media-id "@..." \ # 可选:机器人图标 mediaId,留空用服务端默认图标 + --preview-media-id "@..." # 可选:预览图 mediaId,留空复用 --robot-media-id +# 返回 agentId / robotCode / clientId / clientSecret(secret 仅此一次,务必立即保存) +``` + +> 实现说明:该命令的 MCP 端点在 dws 内**硬编码**(不走服务发现),当前指向「钉钉开放平台应用管理」MCP。它是 MCP 广场的自包含 `?key=` URL,dws transport 对带 key 的 URL 保留 query 并跳过会话 bearer(key 即凭据,详见 `internal/transport`、`internal/app/direct_runtime.go`)。 +> `--robot-media-id` / `--preview-media-id` 的 mediaId 需先经 `media.upload` 上传获得。 + +##### ② Stream 建联:让机器人收→回(无公网 IP / Webhook) + +建号本质只是拿到 `clientId` / `clientSecret`。**拿到这对凭据后,由一个 agent 运行时起 Stream 连接即可驱动收发,与凭据从哪来无关**(API 建号 / 手动从开放平台后台抄,建联这段都一样)。机制(参考实现:[dingtalk-openclaw-connector](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector)): + +1. `new DWClient({ clientId, clientSecret })`(`dingtalk-stream` SDK) +2. `client.registerCallbackListener(TOPIC_ROBOT, handler)` 订阅机器人消息 +3. `client.connect()` 起 WebSocket 直连钉钉网关(**无需公网 IP / Webhook**) +4. 回调里解析 `res.data`,拿 `sessionWebhook` / `conversationId` / `text.content` / `senderStaffId`,转给本地 agent +5. agent 产出回复 → 回 `data.sessionWebhook` + +运行时桥接两条**对等**路径,按宿主选(**不是只支持 OpenClaw**,`peerDependencies.openclaw` 标记为 optional): +- **OpenClaw 及其 fork(如 Hermes)**: 走 Connector 的 `openclaw/plugin-sdk` channel 契约,Stream 直连(上面 1–5 即其内部实现)。把 `clientId` / `clientSecret` 配进账号文件后,`openclaw channels status --deep | grep -i dingtalk` 看 running。 +- **其它 agent(Claude Code / Cursor / Codex / Qoder / opencode / Gemini / Windsurf / Cline / Kiro / Trae 等约 14 个 agent home)**: 通过宿主暴露的 **OpenAI Chat Completions endpoint** 桥接,Connector 把钉钉消息转给该端点再回流;与 DEAP 架构同源。 + +> 账号文件字段、启动、多机器人 `chatbotUserId` 协作等具体配置,以 connector 仓 README / `docs/DINGTALK_MANUAL_SETUP.md` 为准,本 skill 不复制其实现细节。 + +**建联自检(不依赖完整 agent 运行时)**:用 `scripts/bot_stream_probe.js` 最小验证「建联 + 收→回」——起 DWClient 连接、订阅 `TOPIC_ROBOT`、收到 @机器人 消息后经 `sessionWebhook` 回 echo: + +``` +npm i dingtalk-stream +CID=<新clientId> SEC=<新secret> node scripts/bot_stream_probe.js +# 看到 "connect success" = 建联通过;再在钉钉把机器人拉进群 @它 一句, +# 看到 ">>> 收到消息" = 收通过;">>> 回复结果: 200" = 回通过(回复需先过 ③ 的 scope 审批) +``` + +##### ③ 审批开通 + 交叉验证(建号 ≠ 可用,最易踩) + +建号成功 / 能换 `access_token` 只代表【应用凭据】生效;机器人真正收发还需 `qyapi_robot_sendmsg` 等 robot scope **审批开通**。`openclaw channels status` 显示 `connected` / Stream WS 连上**都不是** DingTalk 的能力回执,**不能当作"建联成功"**。唯一可靠判定是真实发消息探测: + +``` +# 用新应用凭据探测真实发消息能力(appKey/appSecret 即 clientId/clientSecret) +TOKEN=$(curl -s -X POST 'https://api.dingtalk.com/v1.0/oauth2/accessToken' \ + -H 'Content-Type: application/json' -d '{"appKey":"<新clientId>","appSecret":"<新secret>"}' | jq -r .accessToken) +curl -s -X POST 'https://api.dingtalk.com/v1.0/robot/groupMessages/send' \ + -H "x-acs-dingtalk-access-token: $TOKEN" -H 'Content-Type: application/json' \ + -d '{"robotCode":"<新clientId>","openConversationId":"__probe__","msgKey":"sampleText","msgParam":"{\"content\":\"probe\"}"}' +# → Forbidden.AccessDenied ...qyapi_robot_sendmsg = 审批未过:照报错里给的 +# open-dev.dingtalk.com/appscope/apply?content=%23qyapi_robot_sendmsg 开通后再用 +# → 参数类错误(非 AccessDenied) = scope 已通,机器人可收发 +``` + +身份交叉验证(appKey ≠ robotCode): `dws chat bot search --client-id <新clientId> --client-secret <新secret> --format json` 查它名下机器人,拿 robotName / robotCode。 + +注意: + - **链路前置**:建号已是原生命令 `dws chat bot create`(MCP 端点硬编码,dws 内可直接闭环);建联需一个 agent 运行时;两段都要 robot scope 审批。前置不满足时明确告知用户,不要伪造创建成功。 + - **建号 ≠ 可用(血泪闸门,最易踩)**:能换 `access_token` 只代表【应用凭据】生效,机器人收发还需 `qyapi_robot_sendmsg` 审批;`connected` 不是能力回执。唯一可靠判定 = 第 ③ 段真实能力探测。 + - **mediaId**:`robotMediaId` / `previewMediaId` 需先经 `media.upload` 上传获得;留空走服务端默认图标。 + - 创建成功后,发消息/拉群仍走上面的 `chat message send-by-bot` / `group members add-bot`,用 `dws chat bot search` 返回的 `robotCode`。 + - 安全: 机器人在你的授权范围内以你的身份行事,按个人助理对待,勿用于无人值守的生产部署。 + ### category (会话分组管理) #### 获取用户自定义会话分组 diff --git a/skills/multi/dingtalk-chat/scripts/bot_stream_probe.js b/skills/multi/dingtalk-chat/scripts/bot_stream_probe.js new file mode 100644 index 00000000..c3b7baed --- /dev/null +++ b/skills/multi/dingtalk-chat/scripts/bot_stream_probe.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * bot_stream_probe.js — 钉钉机器人「收→回」最小验证脚本 + * + * 用 dingtalk-stream SDK 起 DWClient Stream 连接,订阅 TOPIC_ROBOT;收到 + * @机器人 的消息后:ack → 打印消息 → 通过 sessionWebhook 回一条 echo。 + * 用来验证 `dws chat bot create` 建出的机器人「建联 + 收→回」闭环, + * 不依赖完整 OpenClaw/Hermes 运行时(机制等同 connector 的 connection.ts)。 + * + * 用法: + * npm i dingtalk-stream + * CID= SEC= node bot_stream_probe.js + * 然后在钉钉里把该机器人拉进群并 @它 发一句话。 + * + * 退出码:0 正常运行/收到消息;1 连接失败;2 缺凭据。 + * + * 注意(建号 ≠ 可用):连接与收消息只需有效应用凭据;但「回复」需机器人已通过 + * qyapi_robot_sendmsg 审批,未通过时连接/收消息正常、回复会被钉钉拒绝。 + * 审批入口见钉钉报错里的 open-dev.dingtalk.com/appscope/apply?content=%23qyapi_robot_sendmsg + */ +const { DWClient, TOPIC_ROBOT } = require('dingtalk-stream'); + +const clientId = process.env.CID; +const clientSecret = process.env.SEC; +if (!clientId || !clientSecret) { + console.error('缺少凭据:请设置环境变量 CID= SEC='); + process.exit(2); +} + +const client = new DWClient({ clientId, clientSecret }); + +client.registerCallbackListener(TOPIC_ROBOT, async (res) => { + // 立即 ack,避免钉钉重复投递 + const messageId = res.headers && res.headers.messageId; + if (messageId) client.socketCallBackResponse(messageId, { success: true }); + + let data = {}; + try { data = JSON.parse(res.data); } catch (_) { /* ignore */ } + const text = ((data.text && data.text.content) || '').trim(); + console.log('>>> 收到消息:', { + sender: data.senderNick, + conversationType: data.conversationType, // 1=单聊 2=群聊 + text, + sessionWebhook: data.sessionWebhook ? '有' : '无', + }); + + // 通过 sessionWebhook 回一条 echo(最小「回」验证) + if (data.sessionWebhook) { + try { + const r = await fetch(data.sessionWebhook, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ msgtype: 'text', text: { content: `收到:${text || '(空)'}` } }), + }); + console.log('>>> 回复结果:', r.status, await r.text()); + } catch (e) { + console.error('>>> 回复失败:', (e && e.message) || e); + } + } +}); + +client.connect().then(() => { + console.log('Stream 已连接,订阅 TOPIC_ROBOT,等待 @机器人 消息… (Ctrl-C 退出)'); +}).catch((e) => { + console.error('连接失败:', (e && e.message) || e); + process.exit(1); +});