Compare commits

..
10 Commits
Author SHA1 Message Date
修雨 e7a3010b81 docs: cut 1.0.35 changelog + fix README product-count drift (#435)
* docs: cut 1.0.35 changelog + fix README product-count drift

CHANGELOG: promote [Unreleased] to [1.0.35] - 2026-06-08, covering the
chat @-mention render fix (#433), chat list-direct skill alignment (#424),
pat chmod batch agentCode passthrough (#414), and pat JSON auth-URL
readability (#401).

README (en + zh): the multi-skills section claimed 19/20 products while the
Key Services summary says "18 products" (aiapp was taken offline in 1.0.34,
dropping the count to 18). Unify every product-count reference to 18.

* docs(readme): tidy Key Services table — concise descriptions, drop subcommand duplication

The Description column re-listed every subcommand already shown in the
Subcommands column, making rows (esp. chat) very tall and uneven. Rewrite
descriptions as concise, parallel capability summaries and drop the
malformed inline '(top-level: ...)' sprawl in the sheet row. en + zh.

* docs(readme): restore Key Services table; keep only product-count fix

The previous commit silently rewrote/reflowed the entire Key Services
table (shortening every Subcommands + Description cell) — far beyond this
PR's stated "纯文档改动 / product-count drift fix" scope.

Restore both tables (README.md + README_zh.md) byte-for-byte to main and
keep ONLY the six intended count corrections (multi skills 20/19 → 18),
so the diff matches the PR description and the table is not re-wrapped.

* docs(changelog): write 1.0.35 entries in English

The 1.0.35 Fixed entries were in Chinese while all prior releases
(1.0.34, 1.0.33, ...) use English. Translate the four entries (#433,
#424, #414, #401) to English to match the existing CHANGELOG convention;
content unchanged.
2026-06-09 10:32:23 +08:00
修雨 e7ef2c4677 fix(chat): keep @-mention tokens literal so they render (#433)
send_personal_message packs the message body via json.Marshal, whose
default HTML escaping rewrites <@openDingTalkId> / <@all> into
<@...>. The DingTalk client renders an @-mention by matching the
literal <@...> token, so the escaped form is shown as plain text and the
@ never renders (API still returns success, masking the bug).

Marshal the content with SetEscapeHTML(false) for both the group and the
direct send_personal_message paths. Add a regression test asserting the
content keeps literal <@...> tokens and is never HTML-escaped.

Verified live: @someone and @all now render as blue mentions in the client.
2026-06-08 21:46:41 +08:00
修雨 8c2093a41a fix(skill): align chat single-chat docs/script with list-direct (#424)
* fix(skill): align chat single-chat docs/script with list-direct

钉钉 MCP 服务已将单聊从 `chat message list` 拆出独立的 `list-direct` /
`send-direct`(list 现仅支持群聊,--user / --open-dingtalk-id 已移除),
但 skill 侧文档与脚本仍在教 `chat message list --user`,照做会因 unknown
flag 报错;推荐为单聊"优先"方法的 chat_history_with_user.py 也随之失效。

- chat.md (mono+multi): `message list` 改为仅群聊;新增 `list-direct` /
  `send-direct` 两段命令文档;意图路由 / 关键区分 / 上下文传递表 / 注意事项
  全部对齐单聊新命令
- best_practices/01-messaging.md (mono): query-private-chat 由 `list --user`
  改 `list-direct`(multi 版此前已改,未动)
- chat_history_with_user.py (mono+multi): 调用 `list-direct`;并修复返回体
  解析——解包 `result.messages` + 对齐 `createTime/content/sender` 字段,
  此前会崩在 `'str' object has no attribute 'get'`

* docs(changelog): add entry for chat single-chat list-direct alignment (#424)

* fix(skill): drop send-direct from chat docs (not a v1.0.34 command)

复核 v1.0.34 CLI 发现:单聊发送 rpc `send_direct_message_as_user` 在
v1.0.34 已并入 `chat message send`(cli_name=send,用 --user),不再暴露
独立的 `send-direct` 命令(仅 v1.0.29 有)。上一提交按 v1.0.29 误加的
send-direct 文档段/路由/关键区分/上下文表引用对 v1.0.34 是错的,全部移除。

list-direct 部分保留不变——v1.0.34 确认 `list` 仅群聊、`list-direct`
存在,`list --user` 仍报 unknown flag。单聊发送回归 `send --user`。
2026-06-07 16:17:31 +08:00
修雨 5fbf12fe50 Revert "fix(cli): JSON errors to stdout; no interactive confirm on piped stdin (#413)" (#415)
This reverts commit f826375556.
2026-06-05 12:10:17 +08:00
xuanandshangguanxuan.sgx dd419ca498 fix(pat): pass agent code to chmod batch tools (#414)
Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-06-05 11:23:48 +08:00
修雨 f826375556 fix(cli): JSON errors to stdout; no interactive confirm on piped stdin (#413)
- printExecutionError: in JSON mode emit the structured error to stdout
  (not stderr) so machine consumers parsing stdout get a parseable result;
  update the 3 root_execute tests to assert the new contract.
- requireYesForDelete prompt: when stdin is not a TTY (piped/scripted/JSON
  pipelines), do not show an interactive confirm that emits non-JSON and
  blocks on stdin; return a structured validation error requiring --yes.
2026-06-05 11:22:22 +08:00
xuanandshangguanxuan.sgx cb95207d5a fix(pat): keep auth URLs readable in JSON output (#401)
* fix(pat): keep auth URLs readable in JSON output

* fix(pat): narrow URL escaping fix to PAT JSON

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-06-04 15:57:06 +08:00
Ari 1e95d03606 Merge pull request #405 from audanye-sudo/feat/switch-discovery-url
fix: version-code discovery path and take aiapp product offline
2026-06-03 19:45:39 +08:00
修雨 6e3f3cbd24 chore: take aiapp product offline
Remove the entire code surface of the aiapp (AI application: create/query/modify) product:
- Delete internal/helpers/aiapp.go and its test (the init() static command registration)
- Drop aiapp from generator coverage targets and knownRegistryProducts
- Remove aiapp from the operations-analyst persona services in personas.yaml
- Delete skills (mono references/products/aiapp.md + dingtalk-aiapp multi skill + script)
- Update README/README_zh product tables and counts (19->18 products, 20->19 multi skills) and run_skill_tests mapping
- Add a Removed entry to CHANGELOG

The service-discovery side (envelope/discovery.pre.json, Portal-synced and gitignored) removes the aiapp server block separately.
2026-06-03 19:41:05 +08:00
audanye-sudo ce5e919c52 fix(market): version-code discovery path as /cli/discovery/apis/bamboo
Extract the server-list endpoint path into a single discoveryAPIPath constant and move it from /cli/discovery/apis to /cli/discovery/apis/bamboo so future version bumps touch one place.

Only the path changes: the MCP base host stays on production https://mcp.dingtalk.com and the auth / skill / doctor endpoints are untouched. The edition DiscoveryURL hook (full-URL FetchServersFromURL) is unaffected. All mock/test fixtures are updated to the new path and a CHANGELOG 1.0.34 entry is added.
2026-06-03 19:41:04 +08:00
42 changed files with 331 additions and 1527 deletions
+19
View File
@@ -6,6 +6,25 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.35] - 2026-06-08
### Fixed
- **`chat message send` @-mentions not rendered in group / direct chat** (#433, `internal/helpers/chat.go`) — when sending a group message or an openDingTalkId direct message (`send_personal_message`) as the current user, the `content` body was packed with `json.Marshal`, whose default HTML escaping turns the `<` `>` in `<@openDingTalkId>` / `<@all>` into `<` `>`. The DingTalk client renders @-mentions by matching the **literal** `<@...>` token, so after escaping the match fails and the mention shows as plain text — while the API still returns `success`, masking the bug. Fix: add `marshalMessageContent`, which serializes `{title,text}` with `json.Encoder` + `SetEscapeHTML(false)`; both the group and openDingTalkId-direct `send_personal_message` paths now use it, preserving the literal `<@...>`. Added regression test `TestChatMessageSendContentNotHTMLEscaped` asserting the content keeps the literal token and is never HTML-escaped. Verified on a real device: `@someone` and `@all` both render as clickable blue mentions.
- **`chat` skill docs & scripts aligned to direct-chat `list-direct`** (#424) — `chat message list` now supports group chats only (`--user` / `--open-dingtalk-id` removed); reading a direct chat moves to the dedicated `list-direct` command, but the skill docs and scripts still taught `chat message list --user`, which now errors with `unknown flag: --user`, also breaking `chat_history_with_user.py` (listed as the "preferred" way to query direct chats). This update: `skills/{mono,multi/dingtalk-chat}/references/products/chat.md` switches `message list` to group-only and documents the new `list-direct` command, syncing the intent routing / key-distinction / context-passing tables / caveats; `skills/mono/references/best_practices/01-messaging.md` changes query-private-chat from `list --user` to `list-direct` (the multi version was already updated); `chat_history_with_user.py` (mono + multi) now calls `list-direct` and fixes response parsing (unwraps `result.messages`, aligns `createTime/content/sender` fields — it previously crashed on `'str' object has no attribute 'get'`). Direct-chat sending still uses `chat message send --user` (since v1.0.34 the direct-send rpc is folded into the `send` command; there is no separate `send-direct`). Docs/scripts only; no change to CLI binary behavior.
- **`pat chmod` batch authorization did not pass through `agentCode`** (#414, `internal/pat/chmod.go`) — the batch plan / grant paths (`buildBatchPlanArgs` / `batchArgs`) previously carried `agentCode` only in the single-grant `toolArgs`; batch calls omitted it, so a batch authorization with an explicit `agentCode` was processed under the default agent. Fix: the batch plan / grant args now also carry `agentCode`, matching the single-grant path.
- **`pat` JSON output escaped the authorization URL into an unreadable form** (#401, `internal/pat`) — the authorization URL attached to PAT error messages, after default HTML escaping, turned `&` into `&`, breaking the link when copied / recognized on mobile. Fix: the PAT error-enrichment JSON output now uses `SetEscapeHTML(false)` (scoped to PAT JSON only), preserving the readable `&` separators.
## [1.0.34] - 2026-06-03
### Changed
- **Service discovery path now carries a version-coded segment** (`internal/market/registry.go`) — the server-list endpoint moves from `/cli/discovery/apis` to `/cli/discovery/apis/bamboo`. The path is now a single `discoveryAPIPath` constant so future version bumps touch one place. Only the path changes; the MCP base host stays on production `https://mcp.dingtalk.com` and the auth / skill / doctor endpoints are untouched. Discovery via the edition `DiscoveryURL` hook (full-URL `FetchServersFromURL`) is unaffected. Server side must serve the new path.
### Removed
- **`dws aiapp` — AI application product taken offline** — removed the `aiapp` product surface (`create` / `query` / `modify`) from the CLI: deleted `internal/helpers/aiapp.go`, dropped it from the generator coverage targets and `knownRegistryProducts`, removed the `aiapp` skill references (mono `references/products/aiapp.md` + `dingtalk-aiapp` multi skill), and unpublished the `aiapp` server from the service-discovery envelope. Product count drops from 19 to 18.
## [1.0.33] - 2026-06-02
This release merges the multi-contributor `pre-mcp-discovery` feature branch into `main` as a single squash (#391), bringing a large batch of new product surface — full DingTalk **docs** (`doc`), **knowledge base** (`wiki`), **AI app** (`aiapp`), AI-table **forms** + **import/export**, and reworked **mail** / **todo** / **report** command trees — while keeping service discovery pinned to production `https://mcp.dingtalk.com` (the branch's `pre-mcp.dingtalk.com` endpoint change was deliberately excluded; the four host constants in `skill_command.go` / `auth/endpoints.go` / `cli/loader.go` / `market/registry.go` stay on prod). It also folds in the portable auth bundle (`dws auth export` / `import`, #357) and PAT batch authorization (#389).
+4 -5
View File
@@ -71,9 +71,9 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
| Mode | What gets installed | Best for |
|------|----------------------|----------|
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
| **multi** 🧪 **EXPERIMENTAL** | 20 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
| **multi** 🧪 **EXPERIMENTAL** | 18 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 20 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 18 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
How to pick:
@@ -277,7 +277,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
The repo ships a complete Agent Skill system under `skills/`, now organized into two layouts:
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 20 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 18 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
@@ -494,11 +494,10 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| Wiki | `wiki` | 21 | `space` `member` `node` `doc` `file` | Knowledge base management: spaces (`create` / `get` / `list` / `search`), members (`add` / `list` / `update`), node tree, docs & files |
| DevDoc | `devdoc` | 1 | `article` | Search the DingTalk Open Platform documentation |
| AI Search | `aisearch` | 3 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) |
| AI App | `aiapp` | 4 | — | AI application lifecycle: `create` (with prompt / attachments / skills) / `query` (by task ID) / `modify` (by thread ID) |
| Live | `live` | 1 | `stream` | DingTalk live streaming: list my lives |
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
> **334 commands across 19 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
> **330 commands across 18 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
+4 -5
View File
@@ -71,9 +71,9 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
| 模式 | 安装内容 | 适合场景 |
|------|----------|----------|
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
| **multi** 🧪 **试验版 / Preview** | 20 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
| **multi** 🧪 **试验版 / Preview** | 18 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。20 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。18 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
怎么选:
@@ -274,7 +274,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
仓库内置完整的 Agent Skill 体系(`skills/` 目录),目前重组为两套布局:
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 20 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 18 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
@@ -490,11 +490,10 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| 知识库 | `wiki` | 21 | `space` `member` `node` `doc` `file` | 知识库管理:空间(`create` / `get` / `list` / `search`)、成员(`add` / `list` / `update`)、节点树、文档与文件 |
| 开发者文档 | `devdoc` | 1 | `article` | 搜索钉钉开放平台文档 |
| AI 搜问 | `aisearch` | 3 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) |
| AI 应用 | `aiapp` | 4 | — | AI 应用生命周期:`create`(含 prompt / attachments / skills)/ `query`(按任务 ID)/ `modify`(按 thread ID) |
| 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 |
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
> **19 个产品,334 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
> **18 个产品,330 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
+2 -2
View File
@@ -359,7 +359,7 @@ func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadata(t *testing.T
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/cli/discovery/apis":
case r.URL.Path == "/cli/discovery/apis/bamboo":
payload := map[string]any{
"metadata": map[string]any{"count": 2, "nextCursor": ""},
"servers": []any{
@@ -433,7 +433,7 @@ func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadataWhenRegistryT
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/cli/discovery/apis":
case r.URL.Path == "/cli/discovery/apis/bamboo":
_ = json.NewEncoder(w).Encode(map[string]any{
"metadata": map[string]any{"count": 2, "nextCursor": ""},
"servers": []any{
+17 -2
View File
@@ -14,6 +14,7 @@
package app
import (
"bytes"
"context"
"encoding/json"
stderrors "errors"
@@ -229,7 +230,7 @@ func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string {
}
data["openBrowser"] = openBrowser
encoded, err := json.Marshal(payload)
encoded, err := marshalSingleLineJSONNoHTMLEscape(payload)
if err != nil {
return raw
}
@@ -595,7 +596,7 @@ func enrichPATErrorForHostControl(raw string) string {
apperrors.ApplyHostMutations(payload)
// stderr JSON MUST be single-line.
encoded, err := json.Marshal(payload)
encoded, err := marshalSingleLineJSONNoHTMLEscape(payload)
if err != nil {
return raw
}
@@ -636,6 +637,20 @@ func buildPATScopeJSON(scopeErr *PatScopeError, includeHostControl bool) string
return string(b)
}
func marshalSingleLineJSONNoHTMLEscape(v any) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return nil, err
}
out := buf.Bytes()
if len(out) > 0 && out[len(out)-1] == '\n' {
out = out[:len(out)-1]
}
return out, nil
}
// pollPatDeviceFlow polls the PAT device flow status endpoint until a terminal
// state (APPROVED/REJECTED/EXPIRED) is reached or the context is cancelled.
// Returns the final status string and the authCode (non-empty only on APPROVED).
+38
View File
@@ -590,6 +590,29 @@ func makePATErrorJSONWithURI(flowID, clientID, uri string) string {
return string(data)
}
func TestEnrichPATErrorWithOpenBrowserKeepsAuthorizationURLAmpersandReadable(t *testing.T) {
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-copy%26userCode%3DQZYH-D64W#/personalAuthorization?flowId=flow-copy&userCode=QZYH-D64W"
raw := makePATErrorJSONWithURI("flow-copy", "test-client-id", rawURI)
out := enrichPATErrorWithOpenBrowser(raw, true)
if strings.Contains(out, `\u0026`) {
t.Fatalf("enriched PAT JSON should keep URL ampersands readable for mobile copy/linkify, got: %s", out)
}
if !strings.Contains(out, "&userCode=QZYH-D64W") {
t.Fatalf("enriched PAT JSON missing readable authorization URL separator, got: %s", out)
}
var payload map[string]any
if err := json.Unmarshal([]byte(out), &payload); err != nil {
t.Fatalf("json.Unmarshal(enriched PAT payload) error = %v\nraw=%s", err, out)
}
data, _ := payload["data"].(map[string]any)
if got, _ := data["authorizationUrl"].(string); got != rawURI {
t.Fatalf("data.authorizationUrl = %q, want %q", got, rawURI)
}
}
func TestHandlePatAuthCheck_Approved(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "")
server, configDir := setupHandlePATServer(t, "APPROVED", "test-auth-code")
@@ -1082,6 +1105,21 @@ func TestEnrichPATErrorForHostControl_SingleLineOutput(t *testing.T) {
}
}
func TestEnrichPATErrorForHostControlKeepsAuthorizationURLAmpersandReadable(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "agt-sales")
t.Setenv("DINGTALK_AGENT", "sales-copilot")
raw := `{"success":false,"code":"PAT_HIGH_RISK_NO_PERMISSION","data":{"flowId":"flow-host","desc":"授权","uri":"https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-host%26userCode%3DQZYH-D64W#/personalAuthorization?flowId=flow-host&userCode=QZYH-D64W"}}`
out := enrichPATErrorForHostControl(raw)
if strings.Contains(out, `\u0026`) {
t.Fatalf("host PAT JSON should keep URL ampersands readable for mobile copy/linkify, got: %s", out)
}
if !strings.Contains(out, "&userCode=QZYH-D64W") {
t.Fatalf("host PAT JSON missing readable authorization URL separator, got: %s", out)
}
}
// TestBuildPATScopeHostJSON_SingleLineOutput mirrors the above regression
// for the scope-error branch (PAT_SCOPE_AUTH_REQUIRED emission).
func TestBuildPATScopeHostJSON_SingleLineOutput(t *testing.T) {
+2 -2
View File
@@ -24,7 +24,7 @@ func TestCacheRefreshClearsExistingCachesAndSkipsCLISkippedServers(t *testing.T)
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/cli/discovery/apis":
case "/cli/discovery/apis/bamboo":
_ = json.NewEncoder(w).Encode(market.ListResponse{
Metadata: market.ListMetadata{Count: 2},
Servers: []market.ServerEnvelope{
@@ -146,7 +146,7 @@ func TestCacheRefreshHonorsEditionDiscoveryURL(t *testing.T) {
},
},
})
case "/cli/discovery/apis":
case "/cli/discovery/apis/bamboo":
marketHits.Add(1)
http.Error(w, "market endpoint must not be called when edition DiscoveryURL is set", http.StatusNotFound)
default:
+2 -2
View File
@@ -13,13 +13,13 @@ import (
)
// newTestMCPServer returns an httptest.Server that handles both market registry
// and MCP JSON-RPC endpoints. marketOK controls whether /cli/discovery/apis
// and MCP JSON-RPC endpoints. marketOK controls whether /cli/discovery/apis/bamboo
// succeeds, and mcpOK controls whether initialize+tools/list succeed.
func newTestMCPServer(t *testing.T, marketOK, mcpOK bool) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/cli/discovery/apis", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/cli/discovery/apis/bamboo", func(w http.ResponseWriter, r *http.Request) {
if !marketOK {
http.Error(w, "market unavailable", http.StatusInternalServerError)
return
+16 -1
View File
@@ -14,6 +14,7 @@
package errors
import (
"bytes"
"encoding/json"
stderrors "errors"
"fmt"
@@ -462,13 +463,27 @@ func cleanPATJSON(body map[string]any, code string) string {
// stderr JSON MUST be a single-line, directly json.Unmarshal-able
// payload — pretty-printing would break naïve host parsers that read
// stderr line-by-line and fail on leading whitespace.
b, err := json.Marshal(out)
b, err := marshalSingleLineJSONNoHTMLEscape(out)
if err != nil {
return fmt.Sprintf(`{"success":false,"code":"%s"}`, code)
}
return string(b)
}
func marshalSingleLineJSONNoHTMLEscape(v any) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return nil, err
}
out := buf.Bytes()
if len(out) > 0 && out[len(out)-1] == '\n' {
out = out[:len(out)-1]
}
return out, nil
}
// ---- Runner adapter functions ------------------------------------------------
// These match the function signatures referenced by runner.go's PAT check
// framework (ClassifyPatAuthCheck / AsPatAuthCheckError).
+7
View File
@@ -730,6 +730,13 @@ func TestCleanPATJSON_PreservesOpaqueURIVerbatim(t *testing.T) {
result := cleanPATJSON(body, "PAT_MEDIUM_RISK_NO_PERMISSION")
if strings.Contains(result, `\u0026`) {
t.Fatalf("cleanPATJSON should keep URL ampersands readable for mobile copy/linkify, got: %s", result)
}
if !strings.Contains(result, "&userCode=Q8RY-X6E9") {
t.Fatalf("cleanPATJSON output missing readable fragment separator, got: %s", result)
}
var parsed map[string]any
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, result)
+1 -1
View File
@@ -259,7 +259,7 @@ func newDocsMCPGateway(expectations []docsServerExpectation) *httptest.Server {
mux := http.NewServeMux()
server := httptest.NewServer(mux)
mux.HandleFunc("/cli/discovery/apis", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/cli/discovery/apis/bamboo", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
-2
View File
@@ -82,7 +82,6 @@ var writeOperationTokens = map[string]struct{}{
}
var legacy17CoverageTargets = []string{
"aiapp",
"aitable",
"attendance",
"calendar",
@@ -102,7 +101,6 @@ var legacy17CoverageTargets = []string{
}
var extended22CoverageTargets = []string{
"aiapp",
"aitable",
"attendance",
"calendar",
-1
View File
@@ -77,7 +77,6 @@ type RecipeEntry struct {
}
var knownRegistryProducts = map[string]struct{}{
"aiapp": {},
"aidesign": {},
"aitable": {},
"attendance": {},
-189
View File
@@ -1,189 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"encoding/json"
"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"
)
func init() {
RegisterPublic(func() Handler {
return aiappHandler{}
})
}
type aiappHandler struct{}
func (aiappHandler) Name() string {
return "aiapp"
}
func (aiappHandler) Command(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "aiapp",
Short: "AI 应用创建 / 查询 / 修改",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
root.AddCommand(
newAIAppCreateCommand(runner),
newAIAppQueryCommand(runner),
newAIAppModifyCommand(runner),
)
return root
}
func newAIAppCreateCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "create",
Short: "创建 AI 应用",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
prompt, err := aiappRequiredFlag(cmd, "prompt")
if err != nil {
return err
}
params := map[string]any{"prompt": prompt}
if err := addAIAppOptionalInputs(cmd, params); err != nil {
return err
}
return runAIAppTool(cmd, runner, "create_ai_app", params)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("prompt", "", "创建 AI 应用的 prompt (必填)")
cmd.Flags().String("attachments", "", "附件对象数组 JSON")
cmd.Flags().String("skills", "", "技能 ID 列表,逗号分隔")
return cmd
}
func newAIAppQueryCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "query",
Short: "查询 AI 应用任务",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
taskID, err := aiappRequiredFlag(cmd, "task-id")
if err != nil {
return err
}
return runAIAppTool(cmd, runner, "query_ai_app", map[string]any{"taskId": taskID})
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("task-id", "", "AI 应用任务 ID (必填)")
return cmd
}
func newAIAppModifyCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "modify",
Short: "修改 AI 应用",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
prompt, err := aiappRequiredFlag(cmd, "prompt")
if err != nil {
return err
}
threadID, err := aiappRequiredFlag(cmd, "thread-id")
if err != nil {
return err
}
params := map[string]any{
"prompt": prompt,
"threadId": threadID,
}
if skills := aiappStringFlag(cmd, "skills"); skills != "" {
params["officialSkillUids"] = aiappCSV(skills)
}
return runAIAppTool(cmd, runner, "modify_ai_app", params)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("prompt", "", "新的 prompt (必填)")
cmd.Flags().String("thread-id", "", "threadId (必填)")
cmd.Flags().String("skills", "", "技能 ID 列表,逗号分隔")
return cmd
}
func addAIAppOptionalInputs(cmd *cobra.Command, params map[string]any) error {
if attachments := aiappStringFlag(cmd, "attachments"); attachments != "" {
var values []any
if err := json.Unmarshal([]byte(attachments), &values); err != nil {
return apperrors.NewValidation("--attachments must be a JSON array: " + err.Error())
}
if len(values) > 0 {
params["attachments"] = values
}
}
if skills := aiappStringFlag(cmd, "skills"); skills != "" {
params["officialSkillUids"] = aiappCSV(skills)
}
return nil
}
func runAIAppTool(cmd *cobra.Command, runner executor.Runner, tool string, params map[string]any) error {
invocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
"aiapp",
tool,
params,
)
if commandDryRun(cmd) {
return writeCommandPayload(cmd, invocation)
}
result, err := runner.Run(cmd.Context(), invocation)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
func aiappRequiredFlag(cmd *cobra.Command, name string) (string, error) {
if value := aiappStringFlag(cmd, name); value != "" {
return value, nil
}
return "", apperrors.NewValidation("--" + name + " is required")
}
func aiappStringFlag(cmd *cobra.Command, name string) string {
if value, err := cmd.Flags().GetString(name); err == nil && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
return ""
}
func aiappCSV(raw string) []string {
parts := strings.Split(raw, ",")
values := make([]string, 0, len(parts))
for _, part := range parts {
if value := strings.TrimSpace(part); value != "" {
values = append(values, value)
}
}
return values
}
-83
View File
@@ -1,83 +0,0 @@
// 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"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
)
type aiappCommandRunner struct {
last executor.Invocation
}
func (r *aiappCommandRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.last = invocation
return executor.Result{Invocation: invocation}, nil
}
func TestAIAppModifyMatchesWukongPayload(t *testing.T) {
t.Parallel()
runner := &aiappCommandRunner{}
cmd := aiappHandler{}.Command(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"modify",
"--prompt", "根据新图片优化首页视觉风格",
"--thread-id", "THREAD_001",
"--skills", "s1,s2",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.Tool != "modify_ai_app" {
t.Fatalf("tool = %q, want modify_ai_app", runner.last.Tool)
}
if got := runner.last.Params["threadId"]; got != "THREAD_001" {
t.Fatalf("threadId = %#v, want THREAD_001", got)
}
if _, ok := runner.last.Params["attachments"]; ok {
t.Fatalf("attachments should not be sent by modify_ai_app: %#v", runner.last.Params["attachments"])
}
skills, ok := runner.last.Params["officialSkillUids"].([]string)
if !ok || len(skills) != 2 || skills[0] != "s1" || skills[1] != "s2" {
t.Fatalf("officialSkillUids = %#v, want [s1 s2]", runner.last.Params["officialSkillUids"])
}
}
func TestAIAppCreateRejectsInvalidAttachmentsJSON(t *testing.T) {
t.Parallel()
runner := &aiappCommandRunner{}
cmd := aiappHandler{}.Command(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"create", "--prompt", "创建应用", "--attachments", `{"bad":true}`})
err := cmd.Execute()
if err == nil {
t.Fatal("Execute() error = nil, want JSON array validation failure")
}
if runner.last.Tool != "" {
t.Fatalf("tool = %q, want no invocation", runner.last.Tool)
}
}
+19 -4
View File
@@ -14,6 +14,7 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"strings"
@@ -475,11 +476,10 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
if atAll && !strings.Contains(text, "<@all>") {
text = "<@all> " + text
}
b, _ := json.Marshal(map[string]string{"title": title, "text": text})
params := map[string]any{
"openConversationId": group,
"msgType": "markdown",
"content": string(b),
"content": marshalMessageContent(title, text),
}
if atAll {
params["atAll"] = true
@@ -495,11 +495,10 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
params := map[string]any{"title": title, "text": text, "receiverUserId": user}
return params, "send_direct_message_as_user", nil
default:
b, _ := json.Marshal(map[string]string{"title": title, "text": text})
params := map[string]any{
"receiverOpenDingTalkId": openID,
"msgType": "markdown",
"content": string(b),
"content": marshalMessageContent(title, text),
}
if strings.TrimSpace(uuid) != "" {
params["uuid"] = uuid
@@ -1011,3 +1010,19 @@ func jsonMarshal(v any) (string, error) {
}
return string(b), nil
}
// marshalMessageContent builds the send_personal_message content payload
// ({"title","text"}) WITHOUT HTML-escaping < > &. DingTalk's client renders
// @-mentions by matching literal <@openDingTalkId> / <@all> tokens in the
// message text; the default json.Marshal escaping turns them into
// <@...>, which the client shows as plain text instead of a rendered
// mention. encoding/json offers no escape toggle on Marshal, so use an Encoder.
func marshalMessageContent(title, text string) string {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
// Encoder errors are impossible for a map[string]string; ignore safely.
_ = enc.Encode(map[string]string{"title": title, "text": text})
// Encoder.Encode appends a trailing newline; strip it.
return strings.TrimRight(buf.String(), "\n")
}
+49
View File
@@ -222,6 +222,55 @@ func TestChatMessageSendForwardsAtMentions(t *testing.T) {
}
}
// TestChatMessageSendContentNotHTMLEscaped guards the @-mention rendering fix:
// the send_personal_message content must keep literal <@openDingTalkId> / <@all>
// tokens. If json.Marshal's default HTML escaping is reintroduced, the tokens
// become <@...> and the DingTalk client renders them as plain text
// instead of a real @-mention.
func TestChatMessageSendContentNotHTMLEscaped(t *testing.T) {
cases := []struct {
name string
args []string
want string // literal token that must survive in content
}{
{
name: "group-at-all",
args: []string{"--group", "cid-xyz", "--title", "t", "--text", "<@all> hi", "--at-all"},
want: "<@all>",
},
{
name: "group-at-open-dingtalk-id",
args: []string{"--group", "cid-xyz", "--title", "t", "--text", "<@op-1> hi", "--at-open-dingtalk-ids", "op-1"},
want: "<@op-1>",
},
{
name: "direct-open-dingtalk-id",
args: []string{"--open-dingtalk-id", "OP123", "--title", "t", "--text", "<@OP123> hi"},
want: "<@OP123>",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
runner := &captureRunner{}
cmd := newChatMessageSendCommand(runner)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs(tc.args)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
content, _ := runner.last.Params["content"].(string)
if !strings.Contains(content, tc.want) {
t.Fatalf("content %q missing literal %q (HTML-escaped?)", content, tc.want)
}
if strings.Contains(content, "\\u003c") || strings.Contains(content, "\\u003e") {
t.Fatalf("content %q is HTML-escaped; @-mention will not render", content)
}
})
}
}
// TestChatMessageSendRejectsAtMentionsOutsideGroup ensures we do not silently
// drop user intent when --at-* is combined with --user / --open-dingtalk-id
// (single-chat tools have no @-mention semantics, so the flag would never
+7 -1
View File
@@ -33,6 +33,12 @@ import (
const (
defaultBaseURL = "https://mcp.dingtalk.com"
registryMetadataKey = "com.dingtalk.mcp.registry/metadata"
// discoveryAPIPath is the path appended to BaseURL when fetching the
// MCP server list. Kept as a single constant so the version-coded
// segment (".../bamboo") lives in one place and future version bumps
// only touch here. See registry_test.go for the matching fixture path.
discoveryAPIPath = "/cli/discovery/apis/bamboo"
)
type Client struct {
@@ -544,7 +550,7 @@ func (c *Client) FetchServersFromURL(ctx context.Context, fullURL string) (ListR
}
func (c *Client) fetchServersPage(ctx context.Context, limit int, cursor string) (ListResponse, error) {
reqURL, err := url.Parse(c.BaseURL + "/cli/discovery/apis")
reqURL, err := url.Parse(c.BaseURL + discoveryAPIPath)
if err != nil {
return ListResponse{}, apperrors.NewDiscovery("failed to build market servers URL")
}
+2 -2
View File
@@ -194,7 +194,7 @@ func TestFetchServers(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/cli/discovery/apis" {
if r.URL.Path != "/cli/discovery/apis/bamboo" {
t.Fatalf("unexpected path %q", r.URL.Path)
}
payload := ListResponse{
@@ -231,7 +231,7 @@ func TestFetchServersFollowsNextCursor(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/cli/discovery/apis" {
if r.URL.Path != "/cli/discovery/apis/bamboo" {
t.Fatalf("unexpected path %q", r.URL.Path)
}
+7 -3
View File
@@ -227,7 +227,7 @@ grantType 规则:
if c != nil && c.DryRun() {
if usesPlan {
planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, sessionID, true)
planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, agentCode, sessionID, true)
result, err := callPATBatchPlan(cmd.Context(), c, agentCode, sessionID, planArgs)
if err != nil {
return fmt.Errorf("pat chmod plan failed: %w", err)
@@ -255,7 +255,7 @@ grantType 规则:
}
if usesPlan {
planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, sessionID, true)
planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, agentCode, sessionID, true)
planResult, err := callPATBatchPlan(cmd.Context(), c, agentCode, sessionID, planArgs)
if err != nil {
return fmt.Errorf("pat chmod plan failed: %w", err)
@@ -277,6 +277,7 @@ grantType 规则:
"grantType": grantType,
}
if agentCode != "" {
batchArgs["agentCode"] = agentCode
toolArgs["agentCode"] = agentCode
}
if sessionID != "" {
@@ -418,7 +419,7 @@ func callPATBatchPlan(ctx context.Context, c edition.ToolCaller, agentCode, sess
})
}
func buildBatchPlanArgs(scopes []string, productCodes []string, recommend bool, grantType string, sessionID string, dryRun bool) map[string]any {
func buildBatchPlanArgs(scopes []string, productCodes []string, recommend bool, grantType string, agentCode string, sessionID string, dryRun bool) map[string]any {
args := map[string]any{
"scopes": scopes,
"productCodes": productCodes,
@@ -426,6 +427,9 @@ func buildBatchPlanArgs(scopes []string, productCodes []string, recommend bool,
"grantType": grantType,
"dryRun": dryRun,
}
if agentCode != "" {
args["agentCode"] = agentCode
}
if sessionID != "" {
args["sessionId"] = sessionID
}
+18 -6
View File
@@ -305,14 +305,17 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) {
if got := fake.calls[0].args["recommend"]; got != false {
t.Fatalf("recommend = %#v, want false", got)
}
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
t.Fatalf("plan agentCode = %#v, want qoderwork", got)
}
if fake.calls[1].tool != patBatchGrantToolName {
t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName)
}
if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read", "aitable.record:read"}) {
t.Fatalf("grant scopes = %#v, want selected scopes", got)
}
if _, ok := fake.calls[1].args["agentCode"]; ok {
t.Fatalf("batch grant args must not contain agentCode: %#v", fake.calls[1].args)
if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
t.Fatalf("grant agentCode = %#v, want qoderwork", got)
}
}
@@ -339,12 +342,18 @@ func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) {
if got := fake.calls[0].args["sessionId"]; got != "session-123" {
t.Fatalf("plan sessionId = %#v, want session-123", got)
}
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
t.Fatalf("plan agentCode = %#v, want qoderwork", got)
}
if got := fake.calls[1].args["grantType"]; got != "session" {
t.Fatalf("grant grantType = %#v, want session", got)
}
if got := fake.calls[1].args["sessionId"]; got != "session-123" {
t.Fatalf("grant sessionId = %#v, want session-123", got)
}
if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
t.Fatalf("grant agentCode = %#v, want qoderwork", got)
}
}
func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) {
@@ -372,6 +381,9 @@ func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) {
if got := fake.calls[0].args["sessionId"]; got != "env-session-123" {
t.Fatalf("plan sessionId = %#v, want env-session-123", got)
}
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
t.Fatalf("plan agentCode = %#v, want qoderwork", got)
}
}
func TestResolveSessionIDFromEnvMatchesHeaderPriority(t *testing.T) {
@@ -530,8 +542,8 @@ func TestChmod_agentCode_env_fallback(t *testing.T) {
if got := fake.gotAgentEnv; got != "qoderwork" {
t.Fatalf("agent env = %q, want %q", got, "qoderwork")
}
if _, ok := fake.gotArgs["agentCode"]; ok {
t.Fatalf("batch argv must not carry agentCode identity field: %#v", fake.gotArgs)
if got := fake.gotArgs["agentCode"]; got != "qoderwork" {
t.Fatalf("batch argv agentCode = %#v, want qoderwork", got)
}
if got := fake.gotArgs["scopes"]; !stringSliceArgEqual(got, []string{"aitable.record:read"}) {
t.Fatalf("scopes in argv = %#v, want %#v", got, []string{"aitable.record:read"})
@@ -965,8 +977,8 @@ func TestChmod_agentCode_flag_wins_over_env(t *testing.T) {
if got := fake.gotAgentEnv; got != "flagval" {
t.Fatalf("agent env = %q, want %q (flag must win over env)", got, "flagval")
}
if _, ok := fake.gotArgs["agentCode"]; ok {
t.Fatalf("batch argv must not carry agentCode identity field: %#v", fake.gotArgs)
if got := fake.gotArgs["agentCode"]; got != "flagval" {
t.Fatalf("batch argv agentCode = %#v, want flagval", got)
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ personas:
- name: ops-analyst
title: 运营分析师
description: "围绕数据与运营流程执行:表格、听记、邮件与应用协同。"
services: [aitable, minutes, mail, aiapp, drive, doc]
services: [aitable, minutes, mail, drive, doc]
workflows: [minutes-to-todo, drive-upload-and-announce]
instructions:
- "先获取原始数据,再做结构化汇总与分发。"
-2
View File
@@ -35,7 +35,6 @@ cli_version: ">=1.0.15"
| 产品 | 用途 | 参考文件 |
|-------------------|------------------------------------------------------|----------------------------------------------------------------|
| `aiapp` | AI应用:创建/查询/修改AI应用 | [aiapp.md](./references/products/aiapp.md) |
| `aisearch` | AI搜问(搜人首选):按姓名/部门/职位/职责/上级/下级/手机号/工号维度找人,"谁负责 XX/XX 的负责人/某事项/某项目的人"统一走本产品 | [aisearch.md](./references/products/aisearch.md) |
| `aitable` | AI表格:Base/数据表/字段/记录/视图/附件/图表/仪表盘/导入导出/模板搜索 | [aitable.md](./references/products/aitable.md) |
| `attendance` | 考勤:打卡结果/打卡流水/考勤组查询/考勤规则/汇总统计/假期类型/假期余额(P0 已落地,部分管理类命令仍属 P1) | [attendance.md](./references/products/attendance.md) |
@@ -83,7 +82,6 @@ cli_version: ">=1.0.15"
## 意图判断决策树
用户提到"AI应用/创建应用/生成系统/做工具/管理后台/低代码" → `aiapp`
用户提到"找人/搜人/谁负责 XX/某事项的负责人/某项目的人/团队成员/上级/下级/按工号找人/按手机号找人" → `aisearch`
用户提到"表格/多维表/AI表格/记录/数据/视图/图表/仪表盘" → `aitable`
用户提到"考勤/打卡/排班" → `attendance`
@@ -5,7 +5,7 @@
| Recipe | 行动指南(固定路线) |
|--------|-------------------|
| query-group-chat | **优先**:`chat_export_messages.py`(开源版未引入;可手动用 `dws chat message list` 翻页后写入文件)(自动搜群+翻页+导出)<br>备选:1. `chat search --query "<群名>"` → 取 `openConversationId`<br>2. `chat message list --group <openConversationId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表<br>3. **翻页**:`hasMore=true` 时取本页最后 `createTime` 作为下次 `--time`,重复至 `hasMore=false`<br>4. `--forward=false` 拉给定时间**之前**的消息<br>5. 合并全部消息后总结 |
| query-private-chat | **优先**:`chat_history_with_user.py`(开源版未引入;可手动用 `dws chat search` + `dws chat message list` 组合)(自动搜人+翻页+导出)<br>备选:1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`<br>2. `chat message list --user <userId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表<br>3. **翻页**:同 query-group-chat<br>4. 合并全部消息后总结 |
| query-private-chat | **优先**:`chat_history_with_user.py`(开源版未引入;可手动用 `aisearch person` + `dws chat message list-direct` 组合)(自动搜人+翻页+导出)<br>备选:1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`<br>2. `chat message list-direct --user <userId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表(单聊专用;旧版 `list --user` 已停用)<br>3. **翻页**:同 query-group-chat<br>4. 合并全部消息后总结 |
| escalate-ding | 三级升级:<br>1. `ding message send --robot-code <robotCode> --type app --users <userId> --content "<内容>"`(必填项见 [ding.md](../products/ding.md))<br>2. `chat message send --group <openConversationId> --text "<内容>"` 群里提醒(可选 `--title` / `@` 见 [chat.md](../products/chat.md))<br>3. `todo task create --title "<标题>" --executors <userId> --priority 40` 建紧急待办<br>前置:`aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`;`chat search --query "<群名>"` → 取 `openConversationId` |
| send-by-bot | **多群批量优先**:`bot_broadcast.py`(开源版未引入;可手动用 `dws chat message send-by-bot` 多次调用)<br>单群:1. `chat bot search` → 取 `robotCode`<br>2. `chat search --query "<群名>"` → 取 `openConversationId`<br>3. `chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title "<标题>" --text "<内容>"` |
| forward-message | 1. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message list --group <openConversationId> --time "<起始时间>"` 拉源消息<br>2. `contact user search --query "<姓名>"` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取目标 `openConversationId`<br>3. `chat message send --open-dingtalk-id <openDingTalkId> --text "<内容>"`(推荐)或 `--group <openConversationId> --text "<内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user <userId>`(备选) |
-420
View File
@@ -1,420 +0,0 @@
# AI应用 (aiapp) 命令参考
## 强制触发规则
以下意图默认必须优先使用 `aiapp`,即使用户没有说“AI应用”:
- 创建一个新应用
- 创建一个新系统
- 生成一个业务工具
- 按需求生成一个管理后台、工作台应用、表单系统、业务原型
- 修改一个已经生成过的应用
典型触发词包括但不限于:
- 应用、系统、平台、工具、后台、页面、原型、工作台应用
- CRM、OA、库存、仓库、审批、销售、报销、进销存、客户管理、数据录入、查询工具
- 计算器、待办工具、表单应用、信息收集应用、报表应用
- “帮我做一个…”
- “帮我创建一个…”
- “按这个需求生成一个…”
- “把这个需求做成应用”
典型例子:
- “帮我创建个简易的计算器应用”
- “做一个仓库管理系统”
- “生成一个客户管理工具”
- “按这个 PRD 创建一个钉钉应用”
- “修改刚才那个应用,把首页改得更简洁”
不要误路由到其他产品:
- 不要因为用户提到“表格/记录/字段”,就优先走 `aitable`,如果用户的真实目标是“生成一个完整应用”
- 不要因为用户提到“文档/需求”,就优先走 `doc`,如果用户的真实目标是“把需求做成应用”
- 不要因为用户提到“任务/项目”,就优先走 `tb`,如果用户的真实目标是“生成一个管理系统”
只有在以下场景不要走 `aiapp`:
- 用户明确要操作一个现成的多维表、文档、项目、日历、机器人消息
- 用户不是要“生成应用”,而是要“修改已有产品对象”
## 命令总览
### 创建 AI 应用
```
Usage:
dws aiapp create [flags]
Example:
dws aiapp create --prompt "创建一个天气查询应用"
dws aiapp create --prompt "翻译应用" --skills s1,s2
dws aiapp create --prompt "根据附件里的 Excel 创建一个仓库管理应用" --attachments '[{"name":"warehouse.xlsx","type":"excel","url":"https://tmp/warehouse.xlsx"}]'
Flags:
--prompt string 创建 AI 应用的 prompt (必填)
--skills string 技能 ID 列表,逗号分隔
--attachments string 附件对象数组的 JSON 字符串,用于把 Excel/图片等输入材料一起传入
```
### 查询 AI 应用
```
Usage:
dws aiapp query [flags]
Example:
dws aiapp query --task-id <taskId>
Flags:
--task-id string AI 应用任务 ID (必填)
```
### 修改 AI 应用
```
Usage:
dws aiapp modify [flags]
Example:
dws aiapp modify --prompt "改为翻译应用" --thread-id <threadId>
dws aiapp modify --prompt "新描述" --thread-id <threadId> --skills s1,s2
dws aiapp modify --prompt "根据新图片优化首页视觉风格" --thread-id <threadId> --attachments '[{"name":"homepage.png","type":"image/png","url":"https://tmp/homepage.png"}]'
Flags:
--prompt string 新的 prompt (必填)
--skills string 技能 ID 列表,逗号分隔
--thread-id string threadId (必填)
--attachments string 附件对象数组的 JSON 字符串;当前 modify 仅支持图片附件,不支持 Excel
```
## 三个动作的关系
`aiapp` 的三个动作不是彼此独立的,它们描述的是同一个应用任务的生命周期:
1. `create` 用来发起一次新的应用生成任务,返回 `taskId` 和 `threadId`
2. `query` 用来跟踪这次任务是否完成,以及读取生成结果
3. `modify` 用来基于同一个 `threadId` 继续和已生成的应用对话,本质上是对已有应用做增量修改
可以把它理解为:
- `create` = 新建一个应用会话
- `query` = 查看这个会话对应任务的执行状态和结果
- `modify` = 在这个会话里继续追加新的需求,让同一个应用继续演化
关键标识符的关系:
- `taskId` 表示某一次具体的执行任务,主要给 `query` 使用
- `threadId` 表示这个应用的持续会话,主要给 `modify` 使用
- 一次 `create` 会产生一个新的 `taskId`,同时也会产出或确认一个 `threadId`
- 后续每次 `modify` 都是在同一个 `threadId` 上发起新的任务,因此也会产生新的 `taskId`
因此推荐把它们看成一条闭环链路,而不是三个平级命令:
`create -> query -> modify -> query -> modify ...`
## `attachments` 的含义
`attachments` 是 `aiapp` 的输入材料参数,用来把文件和当前任务一起提交给 `aiapp`。
它适合这些场景:
- 根据 Excel 创建应用
- 根据需求文档、表格、截图继续完善应用
- 在修改已有应用时补充新的结构化材料
可以把它理解为:
- `prompt` 描述“希望系统做什么”
- `attachments` 提供“系统可直接参考的文件材料”
### 正确心智
- `attachments` 是 `create` 或 `modify` 的补充输入,不是独立动作
- 即使提供了附件,主动作仍然是 `aiapp create` 或 `aiapp modify`
- 当用户说“按这个 Excel 建一个应用”时,优先走 `aiapp`,并把 Excel 作为 `attachments` 传入
### 数据结构
`attachments` 不是单个字符串,而是对象数组。每个对象当前按以下结构传递:
```json
[
{
"name": "warehouse.xlsx",
"type": "excel",
"url": "https://tmp/warehouse.xlsx",
"size": 102400
}
]
```
每个附件对象当前可用字段:
- `name`:文件名
- `type`:文件类型或 MIME 类型
- `url`:可直接访问的临时下载地址
- `size`:文件大小,可选
如果要传多个附件,就在数组中放多个对象。
### Excel 场景
如果用户上传了 Excel,通常应该:
- 在 `prompt` 里明确写“根据附件中的 Excel 创建应用”或“根据附件继续修改应用”
- 同时把 Excel 通过 `attachments` 传给 `aiapp`
这类需求都应优先视为 `aiapp`:
- “根据这个 Excel 建一个库存管理应用”
- “把附件里的表格做成一个 CRM 应用”
- “按这个 Excel 继续补充刚才那个应用”
### 使用规则
- `create` 可以带图片、Excel 等附件
- `modify` 当前只建议带图片附件,不支持 Excel
- `query` 不使用 `attachments`
- 附件应按对象数组传递,而不是传单个 ID 或随意文本
- 如果用户提供本地文件 + 任意意图 → 先上传文件获取 URL,再执行对应命令
- 每个附件对象通常至少应包含可访问的临时下载地址 `url`
- 如果有附件但没有特别完整的 prompt,也仍然建议补一句明确意图,避免模型误判
### 示例
```bash
# 根据 Excel 创建应用
dws aiapp create --prompt "根据附件里的 Excel 创建一个仓库管理应用" \
--attachments '[{"name":"warehouse.xlsx","type":"excel","url":"https://tmp/warehouse.xlsx"}]' \
--format json
# 在已有应用上继续结合图片修改
dws aiapp modify --prompt "根据新图片优化首页布局和视觉风格" \
--thread-id <threadId> \
--attachments '[{"name":"homepage.png","type":"image/png","url":"https://tmp/homepage.png"}]' \
--format json
```
## `--skills` 的含义
`--skills` 是 `aiapp` 的附加能力参数,不是一个独立动作。
它的作用不是“先执行这些技能,再单独执行 `aiapp`”,而是:
- 在 `create` 时,把这些官方技能作为本次应用生成任务的附加约束或附加能力一起传入
- 在 `modify` 时,把这些官方技能作为本次修改任务的附加约束或附加能力一起传入
可以把它理解为:
- `prompt` 描述“你想做什么应用”
- `--skills` 描述“这次生成或修改时,要额外挂载哪些官方能力或规范”
因此 `--skills` 必须依附于 `create` 或 `modify` 使用,本身不能单独完成创建应用。
### 正确心智
- `aiapp` 是主动作
- `--skills` 是主动作的附加参数
- 用户是否挂载官方技能,会影响应用生成或修改效果
- 即使用户提到了某个官方技能,最终仍然应该优先调用 `aiapp create` 或 `aiapp modify`
### 常见误区
不要把以下场景理解错:
- 用户说“创建一个应用,并遵循设计规范”
- 正确做法:调用 `aiapp create ... --skills <design-skill-id>`
- 不正确做法:把“设计规范技能”当成一个和 `aiapp` 平级、单独执行的动作
- 用户说“继续修改刚才那个应用,并带上设计规范”
- 正确做法:调用 `aiapp modify ... --thread-id <threadId> --skills <design-skill-id>`
- 不正确做法:先执行设计规范技能,再单独调用 `modify`
- 用户只是在描述“希望页面更漂亮、更标准”
- 如果已知对应的官方技能 ID,可以通过 `--skills` 传入
- 如果没有明确的技能 ID,不要编造,优先把需求写进 `prompt`
### 什么时候传 `--skills`
适合传 `--skills` 的情况:
- 用户明确要求遵循某个已知的官方能力、官方规范、官方模板
- 当前上下文里已经拿到了可用的官方技能 ID/Code
- 需要在创建或修改时显式挂载这些技能
不适合传 `--skills` 的情况:
- 没有明确的技能 ID
- 只是模糊地希望“更专业”“更好看”,但没有对应官方技能可挂
- 技能本身不是 `aiapp` 官方子技能,而是其他产品的独立操作能力
### 使用规则
- `create` 和 `modify` 都可以带 `--skills`
- `query` 不使用 `--skills`
- `--skills` 里的值必须是技能 ID,多个值用逗号分隔
- 不要猜测、编造技能 ID,必须来自已知上下文或系统返回
### 示例
```bash
# 创建应用,并挂载一个钉钉设计规范技能
dws aiapp create --prompt "创建一个仓库管理应用" \
--skills dingtalk_design_spec --format json
# 修改已有应用,并继续带上官方技能
dws aiapp modify --prompt "增加个库存大盘图表页" \
--thread-id <threadId> \
--skills dingtalk_design_spec --format json
```
## 意图判断
用户说以下任何一种,都走 `create`:
- “做个应用 / 创建应用 / 生成应用 / 搭个应用”
- “做个系统 / 平台 / 后台 / 工具 / 页面”
- “帮我做个计算器 / 仓库管理 / CRM / OA / 审批 / 报表 / 信息收集应用”
- “按这段需求生成一个钉钉应用”
用户说以下任何一种,都走 `query`:
- “查看应用状态”
- “这个应用创建好了没”
- “查一下 taskId 对应的进度”
- “继续轮询应用创建进展”
用户说以下任何一种,都走 `modify`:
- “修改应用”
- “更新刚才那个应用”
- “基于这个 thread/task 继续改”
- “把首页改一下 / 把功能补上 / 换个风格”
## 核心工作流
```bash
# 1. 创建 AI 应用 — 提取 taskId 和 threadId
dws aiapp create --prompt "创建一个天气查询应用" --format json
# 2. 查询应用状态
dws aiapp query --task-id <taskId> --format json
# 3. 修改应用
dws aiapp modify --prompt "新描述" --thread-id <threadId> \
--skills skill1,skill2 --format json
```
补充理解:
- 如果用户还没有现成应用,先走 `create`
- 如果用户刚发起了创建,通常先走 `query` 看是否完成
- 如果用户说“继续改刚才那个应用”,优先找上一次返回里的 `threadId`,走 `modify`
- `modify` 完成后,仍然可以继续用新的 `taskId` 走 `query`
- 不要把 `modify` 理解成“覆盖旧应用”,它更像是在同一个应用会话中追加新需求
- `attachments` 不改变 `create/query/modify` 的主流程,它只是给 `create` 或 `modify` 增加文件输入
- 如果附件是 Excel,优先理解为 `create` 场景;`modify` 当前不要用 Excel 附件
- `--skills` 不改变 `create/query/modify` 的主流程,它只是给 `create` 或 `modify` 增加官方附加能力
推荐决策顺序:
1. 先判断用户是不是要“生成一个新的应用/系统”
2. 如果是,直接用 `aiapp create`
3. 如果用户是在已有应用基础上继续改,用 `aiapp modify`
4. 如果只是查进度或结果,用 `aiapp query`
## 查询与轮询预期
`aiapp` 的创建和修改通常不是秒级任务,而是分钟级任务。
- 简单应用通常需要几分钟
- 复杂应用可能持续十几分钟到几十分钟
因此在使用 `query` 时,不要假设“连续几次没有完成”就代表任务卡死。
### 正确的轮询策略
- 不要每 5-10 秒高频轮询
- 建议以 30 秒为默认轮询间隔
- 如果任务已经明确进入长流程,可进一步放宽到 60 秒
- 除非用户明确要求实时刷新,否则不要自行创建过于频繁的定时查询
### 代理行为约束
当 agent 已经帮用户发起了 `aiapp create` 或 `aiapp modify` 后,不要只查询 1-2 次就把后续进度甩给用户自己处理。
- 只要任务仍处于 `queued` 或 `running`,且用户没有明确要求停止,agent 应继续主动跟踪进度
- 如果当前会话无法持续等待,可以创建定时任务继续查询,但定时任务必须是“以拿到最终结果为目标”的,而不是无条件永久轮询
- 如果返回了 `threadViewUrl`,可以告诉用户“你也可以点这个链接查看实时进度”,但这不能替代 agent 自己继续追踪任务
### 定时任务规则
如果 agent 选择创建定时任务来继续查询 `aiapp` 状态,必须同时设置结束条件:
- 当 `status = succeeded` 且已拿到 `appPreviewUrl` 时,立即停止后续查询,并主动删除或关闭该定时任务
- 当 `status = failed` 时,立即停止后续查询,并主动删除或关闭该定时任务
- 不要在已经拿到目标结果后继续保留定时任务
- 不要创建“只会查询、不会自终止”的定时任务
换句话说,定时任务应当是“查询直到成功或失败,然后自动结束”的一次性追踪任务,而不是长期后台轮询
### 如何理解 `query` 返回
`query` 的返回里,除了总状态外,还可能包含进度信息,例如:
- 当前正在执行哪个步骤
- 已完成多少步骤
- 最近一次活动时间
- 当前步骤列表
- 当前 thread 的访问链接
因此判断任务是否还在推进时,不要只看 `status` 是否还是 `running`,还要结合以下字段一起看:
- `updatedAt` / `lastActivityAt`
- `progress.summary`
- `progress.currentStep`
- `progress.steps`
- `threadViewUrl`
只要这些字段仍在变化,通常就说明任务仍在正常推进。
### `threadViewUrl` 与 `appPreviewUrl` 的区别
`query` 或 `create` 的返回里,可能会有两个不同的链接:
- `threadViewUrl`:当前 thread 的访问链接。用于打开 `aiapp` 站点里的会话/生成进度页面,适合在应用还没生成完成时查看实时状态
- `appPreviewUrl`:最终生成出的应用访问地址。只有应用创建成功后才会出现,适合真正打开应用去使用
不要把这两个链接混用:
- 任务还在 `queued/running` 时,优先使用 `threadViewUrl`
- 任务 `succeeded` 且结果里已有 `appPreviewUrl` 时,再使用 `appPreviewUrl`
### 何时停止轮询
- 当 `status = succeeded` 时,停止轮询,并从结果中提取 `threadId`、应用链接等信息
- 当 `status = failed` 时,停止轮询,并向用户报告失败
- 当任务长时间保持 `running`,但 `lastActivityAt` 和 `progress` 都没有变化时,再考虑提示可能卡住,而不是过早下结论
- 如果此前为查询进度创建了定时任务,在 `succeeded` 或 `failed` 后要主动删除该定时任务,而不是留给用户处理
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `create` | `taskId`, `threadId`, `threadViewUrl` | query 的 --task-id, modify 的 --thread-id,或直接打开进度页 |
| `query` | `threadViewUrl`, `appPreviewUrl`, 应用详情 | 查看进度页,或在成功后打开应用预览并决定是否 modify |
## 注意事项
- `create` 通过自然语言 prompt 描述应用功能,系统自动生成
- 对“应用/系统/工具/后台/原型”类需求,优先使用 `aiapp`,不要改用其他产品命令模拟实现
- 对“根据附件 / 根据 Excel / 根据上传表格生成应用”这类需求,也优先使用 `aiapp`
- `attachments` 需要按对象数组传递,当前结构是 `[{name,type,url,size}]`
- `create` 可使用 Excel 附件;`modify` 当前仅支持图片附件,不支持 Excel
- `--skills` 传入官方技能 UID 列表,逗号分隔,可增强应用能力
- `modify` 需要 `--thread-id` 来标识修改哪个会话线程
- 应用未生成完成前,如果需要让用户查看实时状态,优先使用返回里的 `threadViewUrl`
- 应用生成成功后,如果需要真正打开应用,再使用 `appPreviewUrl`
- 创建后可通过 `query` 轮询 `taskId` 查看创建进度,但轮询间隔默认应为 20-30 秒,不要 10 秒级频繁轮询
- agent 不应只查 1-2 次就放弃;如果任务仍在运行,应继续跟踪,或创建带终止条件的定时任务继续跟踪
- 如果 agent 创建了用于查询进度的定时任务,在任务成功或失败后必须主动删除或结束该定时任务
- 不要直接使用用户本地文件路径作为附件 URL,必须先上传转换为互联网可访问链接
## 自动化脚本
| 脚本 | 场景 | 用法 |
|------|------|------|
| [aiapp_create_and_poll.py](../../scripts/aiapp_create_and_poll.py) | 一键创建 AI 应用并自动轮询进度直到完成 | `python aiapp_create_and_poll.py --prompt "创建一个天气查询应用"` |
+38 -19
View File
@@ -338,35 +338,54 @@ Flags:
### message (会话消息管理)
#### 拉取会话消息内容 — 拉取指定群聊或单聊的会话消息内容
#### 拉取群聊会话消息内容 — 拉取指定群聊的会话消息内容(仅群聊)
--group 指定群聊,--user 指定单聊用户(通过 userId),--open-dingtalk-id 指定单聊用户(通过 openDingTalkId),三者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
--group 指定群聊 openConversationId(**本命令仅支持群聊**;拉取单聊/私聊消息请改用 `chat message list-direct`)。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
```
Usage:
dws chat message list [flags]
Example:
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00"
dws chat message list --user <userId> --time "2025-03-01 00:00:00" --limit 50
dws chat message list --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 50
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --limit 50
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --forward=false
# 拉单聊改用 list-direct: dws chat message list-direct --user <userId> --time "2025-03-01 00:00:00"
Flags:
--forward true=拉给定时间之后的消息,false=拉给定时间之前的消息 (default true)
--group string 群聊 openconversation_id(群聊时必填)
--group string 群聊 openconversation_id(必填,**仅支持群聊**;查单聊用 chat message list-direct --user <userId>)
--limit int 返回数量,不传则不限制
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
--user string 单聊用户 userId(单聊时与 --open-dingtalk-id 二选一)
--open-dingtalk-id string 单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景)
注意:
- --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id
- --user 和 --open-dingtalk-id 都是发起单聊消息拉取,区别在于用不同格式的用户标识:
- --user 传 userId(企业内部应用常用)
- --open-dingtalk-id 传 openDingTalkId(三方应用或跨组织场景常用,无法获取 userId 时使用)
- 本命令**仅支持群聊**,必须指定 --group;拉取单聊(私聊)消息请改用 `chat message list-direct`(旧版的 `list --user` / `list --open-dingtalk-id` 已不再支持)
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- 翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time
- 话题圈消息拉取流程:如果返回的会话消息中包含 openConvThreadId 字段,说明是话题类消息。要获取完整的话题内容,需要两步操作:(1) 先通过 dws chat message list 拉取话题主消息(即话题帖子本身);(2) 再调用 dws chat message list-topic-replies --group <openConversationId> --topic-id <openConvThreadId> 分页拉取该话题下的所有回复消息。只有话题主消息 + 回复列表合在一起,才是一条话题的完整内容。
```
#### 拉取单聊消息内容 — 按对方 userId 拉取与某同事的单聊(私聊)历史消息
按对方 userId(或 openDingTalkId)拉取与该同事的单聊会话消息,**专用于私聊**;查群聊请用 `chat message list --group`。同组织内同事用 --user,非同组织好友用 --open-dingtalk-id,二者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
```
Usage:
dws chat message list-direct [flags]
Example:
dws chat message list-direct --user <对方userId> --time "2025-03-01 00:00:00" --limit 50
dws chat message list-direct --user <对方userId> --time "2025-03-01 00:00:00" --forward=false
dws chat message list-direct --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 20
# 查询对方 userId: dws contact user search --keyword "姓名" 或 dws aisearch person --keyword "姓名" --dimension name
Flags:
--forward true=从老往新(给定时间之后),false=从新往老(给定时间之前) (default true)
--user string 对方 userId(同组织内同事,与 --open-dingtalk-id 二选一)
--open-dingtalk-id string 对方 openDingTalkId(非同组织普通好友场景,与 --user 二选一)
--limit int 每页返回数量(默认 50)
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
注意:
- --user 与 --open-dingtalk-id 二选一,必须且只能指定其一;同组织同事优先用 --user
- --time 必填;翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time
- 本命令是 `chat message list` 拆分出的单聊专用命令;查群聊消息请用 `chat message list --group`
```
#### 以当前用户身份发送消息 — --group 群聊 / --user 或 --open-dingtalk-id 单聊
**重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。**
@@ -592,7 +611,7 @@ Flags:
注意:
- 四个参数每次请求都会传递给服务端,cursor 首页传 "0"
- 与 chat message list 的区别:list 拉取指定单个会话(群聊或单聊)的消息,list-all 拉取当前用户所有会话的消息
- 与 chat message list 的区别:list 拉取指定群聊会话的消息(单聊用 list-direct),list-all 拉取当前用户所有会话的消息
- 翻页:hasMore=true 时,用响应中的 nextCursor 值作为下次 --cursor 参数继续翻页
- 时间格式统一为 yyyy-MM-dd HH:mm:ss
```
@@ -1147,7 +1166,7 @@ Flags:
用户说"我特别关注的人最近发了什么消息/关注的人最近聊了啥/星标联系人最近的动态" → `chat message list-focused`(零参数一行命令)
用户说"某人发给我的消息/指定发送者的消息/某人最近的消息" → `chat message list-by-sender --sender-user-id <userId>` 或 `--sender-open-dingtalk-id <openDingTalkId>`(跨单聊+群聊)
用户说"和某人的单聊聊天记录/拉某人单聊历史" → `chat message list --user <userId>` 或 `--open-dingtalk-id <openDingTalkId>`
用户说"和某人的单聊聊天记录/拉某人单聊历史" → `chat message list-direct --user <userId>` 或 `--open-dingtalk-id <openDingTalkId>`
用户说"某个群的聊天记录" → `chat message list --group <openConversationId>`
用户说"我最近所有消息/我今天的消息" → `chat message list-all --start <ISO> --end <ISO>`
用户说"@我的消息/提及我的" → `chat message list-mentions --start <ISO> --end <ISO>`
@@ -1167,7 +1186,7 @@ Flags:
用户说"改群名" → `chat group rename`
用户说"聊天记录/会话消息/拉取会话" → `chat message list`
用户说"某人发给我的消息/指定发送者/某人的消息" → `chat message list-by-sender`(用户未明确说"单聊"时优先使用,跨单聊/群聊)
用户说"拉取和某人的单聊记录/单聊消息" → `chat message list --user`(用户明确说"单聊"时使用)
用户说"拉取和某人的单聊记录/单聊消息" → `chat message list-direct --user`(单聊专用;用户明确说"单聊"时使用)
用户说"@我的消息/at我的/提及我的" → `chat message list-mentions`
用户说"未读消息会话/未读会话列表/我的未读会话" → `chat message list-unread-conversations`
用户说"发群消息(以个人身份)" → `chat message send --group`
@@ -1222,8 +1241,8 @@ Flags:
关键区分:
- `chat search` — 搜**群/会话名**返回 `openConversationId`,**不**搜消息内容;要搜消息内容请用 `chat message search-advanced`(首选)/ `chat message search` / `list-by-sender` / `list-all`,**勿混淆**
- `chat message list` — 拉取指定会话的消息(需指定 --group 或 --user),按时间点 + 方向翻页
- `chat message list --user` — list 的单聊模式,拉取与指定用户的单聊记录(用户明确说"单聊""私聊"时使用)
- `chat message list` — 拉取指定**群聊**的消息(需指定 --group,**仅群聊**),按时间点 + 方向翻页
- `chat message list-direct` — 单聊专用,拉取与指定用户的单聊(私聊)记录(--user / --open-dingtalk-id;用户明确说"单聊""私聊"时使用)
- `chat message list-by-sender` — 搜索指定发送者发给我的消息,跨所有会话(单聊+群聊均包含,用户只说"某人发的消息"时优先使用)
- `chat message list-mentions` — 拉取 @我 的消息(跨单聊/群聊,可选指定群)
- `chat message list-unread-conversations` — 拉取当前用户存在未读消息的会话列表(可选 `--count`)
@@ -1448,8 +1467,8 @@ Flags:
| `chat search` | `openConversationId` | message send/list、group members 等的 --group |
| `chat group create` | `openConversationId` | 同上 |
| `chat message list-all` | `nextCursor` | 下次 list-all 的 --cursor |
| `aisearch person` | `userId` | message send 的 --user、send-by-bot 的 --users、send-by-bot 的 --at-user-ids、list-by-sender 的 --sender-user-id |
| `aisearch person` → `contact user get` | `openDingTalkId` | message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、send-by-bot 的 --open-dingtalk-ids、send-by-bot 的 --at-open-dingtalk-ids、list-by-sender 的 --sender-open-dingtalk-id、message list 的 --open-dingtalk-id |
| `aisearch person` | `userId` | message send 的 --user、list-direct 的 --user、send-by-bot 的 --users、send-by-bot 的 --at-user-ids、list-by-sender 的 --sender-user-id |
| `aisearch person` → `contact user get` | `openDingTalkId` | message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、list-direct 的 --open-dingtalk-id、send-by-bot 的 --open-dingtalk-ids、send-by-bot 的 --at-open-dingtalk-ids、list-by-sender 的 --sender-open-dingtalk-id |
| `chat bot search` | `robotCode` | send-by-bot / recall-by-bot 的 --robot-code(仅我创建的机器人,无 openDingTalkId) |
| `chat bot find` | `openDingTalkId` | 给机器人发单聊消息(全部可用机器人,额外返回 openDingTalkId) |
| `chat message send-by-bot` | `processQueryKey` | recall-by-bot 的 --keys |
@@ -1484,7 +1503,7 @@ Flags:
- `--group` 为群聊会话 ID (openconversation_id),可从群搜索或群聊信息中获取
- `chat message send` 的 text 是位置参数(恰好 1 个),非 flag;群聊用 `--group`,单聊用 `--user`(userId)或 `--open-dingtalk-id`(openDingTalkId),三者互斥;纯文本/Markdown 单聊传 `--user` 时直接走 userId 发送能力;`--at-all`、`--at-open-dingtalk-ids` 仅在 `--group` 群聊时生效;富媒体消息通过 `--msg-type` 指定类型(image/file),必须显式指定;发送文件/媒体消息时,必须先根据文件扩展名判断 msgType:图片→image,其他所有→file,不可跳过此判断
- `chat message list-all` 的四个参数(--start、--end、--limit、--cursor)每次请求都必须传递;翻页时用响应中的 nextCursor 值作为下次 --cursor
- `chat message list` 的 `--group`、`--user`、`--open-dingtalk-id` 三者互斥,必须且只能指定其一
- `chat message list` **仅支持群聊**,必须指定 `--group`;拉取单聊用 `chat message list-direct`(`--user` / `--open-dingtalk-id` 二选一)
- `chat message list-by-sender` 不需要指定单聊/群聊,返回结果自带会话类型标识;`--sender-user-id`(userId)与 `--sender-open-dingtalk-id`(openDingTalkId)二选一;时间用 `--start`/`--end`(ISO-8601),分页用 `--limit`/`--cursor`
- `chat message list-mentions` 可选 `--group` 指定群聊,不传则查全部;时间用 `--start`/`--end`(ISO-8601),分页用 `--limit`/`--cursor`
- `chat message list-unread-conversations` 获取当前用户未读会话列表,可选 `--count` 指定返回条数
@@ -1,138 +0,0 @@
#!/usr/bin/env python3
"""
创建 AI 应用并自动轮询等待完成
用法:
python aiapp_create_and_poll.py \
--prompt "创建一个仓库管理应用"
python aiapp_create_and_poll.py \
--prompt "生成客户管理 CRM" \
--skills skill1,skill2 \
--interval 30 \
--timeout 600
python aiapp_create_and_poll.py --dry-run --prompt "test"
"""
import sys
import json
import subprocess
import argparse
import time
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return {'dry_run': True}
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=120
)
if result.returncode != 0:
print(f" ✗ 错误:{result.stderr.strip()}")
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f" ✗ 错误:{e}")
return None
def main():
parser = argparse.ArgumentParser(
description='创建 AI 应用并轮询等待完成'
)
parser.add_argument(
'--prompt', required=True, help='应用描述'
)
parser.add_argument(
'--skills', default='', help='技能 ID 列表'
)
parser.add_argument(
'--interval', type=int, default=30,
help='轮询间隔秒 (默认 30)',
)
parser.add_argument(
'--timeout', type=int, default=600,
help='最大等待秒 (默认 600)',
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
print(f'🚀 创建 AI 应用...')
print(f' Prompt: {args.prompt}')
cmd_args = [
'aiapp', 'create',
'--prompt', args.prompt,
'--format', 'json',
]
if args.skills:
cmd_args.extend(['--skills', args.skills])
create_data = run_dws(cmd_args, dry_run=args.dry_run)
if args.dry_run:
run_dws([
'aiapp', 'query',
'--task-id', '<TASK_ID>',
'--format', 'json',
], dry_run=True)
return
if not create_data:
sys.exit(1)
task_id = create_data.get('taskId') or create_data.get('id', '')
thread_id = create_data.get('threadId', '')
print(f" ✓ 任务已创建")
print(f" taskId: {task_id}")
print(f" threadId: {thread_id}")
print(f'\n⏳ 轮询等待 (间隔 {args.interval}s, '
f'超时 {args.timeout}s)...')
elapsed = 0
while elapsed < args.timeout:
time.sleep(args.interval)
elapsed += args.interval
query_data = run_dws([
'aiapp', 'query',
'--task-id', task_id,
'--format', 'json',
])
if not query_data:
print(f" [{elapsed}s] ⚠ 查询失败,继续等待...")
continue
status = (query_data.get('status')
or query_data.get('state', 'unknown'))
progress = query_data.get('progress', {})
step = ''
if isinstance(progress, dict):
step = progress.get('currentStep', '')
if status == 'succeeded':
print(f" [{elapsed}s] ✅ 应用创建成功!")
if thread_id:
print(f" threadId: {thread_id}")
return
elif status == 'failed':
print(f" [{elapsed}s] ❌ 创建失败")
sys.exit(1)
else:
info = f" [{elapsed}s] ⏳ {status}"
if step:
info += f" ({step})"
print(info)
print(f"\n⏰ 超时 ({args.timeout}s),任务可能仍在运行")
print(f" 可手动查询: dws aiapp query --task-id {task_id}")
if __name__ == '__main__':
main()
+13 -6
View File
@@ -9,7 +9,7 @@
工作流:
1. 通过 --name 搜索通讯录,获取 userId(或直接传 --user)
2. 调用 chat message list --user <userId> 拉取单聊消息
2. 调用 chat message list-direct --user <userId> 拉取单聊消息
3. 输出到终端或导出为 JSON 文件
"""
@@ -116,7 +116,7 @@ def main():
while page < max_pages and remaining > 0:
cmd_args = [
'chat', 'message', 'list',
'chat', 'message', 'list-direct',
'--user', user_id or '<USER_ID>',
'--time', current_time,
'--format', 'json',
@@ -139,8 +139,12 @@ def main():
page_msgs = data
has_more = False
else:
page_msgs = data.get('messages', data.get('result', []))
has_more = data.get('hasMore', False)
# list-direct 返回 {result: {hasMore, messages: [...]}},先解包 result
result_obj = data.get('result', data)
if not isinstance(result_obj, dict):
result_obj = data
page_msgs = result_obj.get('messages', result_obj.get('records', []))
has_more = result_obj.get('hasMore', data.get('hasMore', False))
if not page_msgs:
break
@@ -153,7 +157,8 @@ def main():
break
last_msg = page_msgs[-1]
boundary_time = last_msg.get('createAt') or last_msg.get('time', '')
boundary_time = (last_msg.get('createTime') or last_msg.get('createAt')
or last_msg.get('time', ''))
if not boundary_time or boundary_time == current_time:
break
current_time = boundary_time
@@ -183,9 +188,11 @@ def main():
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
else:
for m in all_messages:
if not isinstance(m, dict):
continue
sender = m.get('senderNick') or m.get('sender', '未知')
text = m.get('text') or m.get('content', '')
time_str = m.get('createAt') or m.get('time', '')
time_str = m.get('createTime') or m.get('createAt') or m.get('time', '')
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
-35
View File
@@ -1,35 +0,0 @@
---
name: dingtalk-aiapp
description: 钉钉 AI 应用生成。Use when 用户说 创建应用/生成系统/做工具/管理后台/工作台应用/表单系统/业务原型/页面/平台。强制触发:用户提到「应用 / 系统 / 平台 / 工具 / 后台 / 页面 / 原型」时优先匹配此 skill。Distinct from dingtalk-workbench(工作台应用列表)、dingtalk-wiki(知识库)。命令前缀:dws aiapp。
cli_version: ">=0.2.14"
metadata:
category: product
stability: experimental
requires:
bins:
- dws
---
# 钉钉 AI 应用 Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH.
<!-- SAFETY_PREAMBLE_INJECT -->
> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws <cmd> --help` 或 `--dry-run` 验证。
> 命令参考:[aiapp.md](references/aiapp.md)。
## 意图表
| 用户说 | 命令 |
|--------|------|
| "创建一个 XX 应用 / 系统 / 工具" | `python scripts/aiapp_create_and_poll.py --prompt "<用户原始需求>"`(自动轮询进度) |
| "查 AI 应用 / 修改已有应用" | 见 [aiapp.md](references/aiapp.md) |
## 跨产品协作
- 已存在的工作台应用列表 → 切到 `dingtalk-workbench`
@@ -1,417 +0,0 @@
# AI应用 (aiapp) 命令参考
## 强制触发规则
以下意图默认必须优先使用 `aiapp`,即使用户没有说“AI应用”:
- 创建一个新应用
- 创建一个新系统
- 生成一个业务工具
- 按需求生成一个管理后台、工作台应用、表单系统、业务原型
- 修改一个已经生成过的应用
典型触发词包括但不限于:
- 应用、系统、平台、工具、后台、页面、原型、工作台应用
- CRM、OA、库存、仓库、审批、销售、报销、进销存、客户管理、数据录入、查询工具
- 计算器、待办工具、表单应用、信息收集应用、报表应用
- “帮我做一个…”
- “帮我创建一个…”
- “按这个需求生成一个…”
- “把这个需求做成应用”
典型例子:
- “帮我创建个简易的计算器应用”
- “做一个仓库管理系统”
- “生成一个客户管理工具”
- “按这个 PRD 创建一个钉钉应用”
- “修改刚才那个应用,把首页改得更简洁”
不要误路由到其他产品:
- 不要因为用户提到“表格/记录/字段”,就优先走 `aitable`,如果用户的真实目标是“生成一个完整应用”
- 不要因为用户提到“文档/需求”,就优先走 `doc`,如果用户的真实目标是“把需求做成应用”
- 不要因为用户提到“任务/项目”,就优先走 `tb`,如果用户的真实目标是“生成一个管理系统”
只有在以下场景不要走 `aiapp`:
- 用户明确要操作一个现成的多维表、文档、项目、日历、机器人消息
- 用户不是要“生成应用”,而是要“修改已有产品对象”
## 命令总览
### 创建 AI 应用
```
Usage:
dws aiapp create [flags]
Example:
dws aiapp create --prompt "创建一个天气查询应用"
dws aiapp create --prompt "翻译应用" --skills s1,s2
dws aiapp create --prompt "根据附件里的 Excel 创建一个仓库管理应用" --attachments '[{"name":"warehouse.xlsx","type":"excel","url":"https://tmp/warehouse.xlsx"}]'
Flags:
--prompt string 创建 AI 应用的 prompt (必填)
--skills string 技能 ID 列表,逗号分隔
--attachments string 附件对象数组的 JSON 字符串,用于把 Excel/图片等输入材料一起传入
```
### 查询 AI 应用
```
Usage:
dws aiapp query [flags]
Example:
dws aiapp query --task-id <taskId>
Flags:
--task-id string AI 应用任务 ID (必填)
```
### 修改 AI 应用
```
Usage:
dws aiapp modify [flags]
Example:
dws aiapp modify --prompt "改为翻译应用" --thread-id <threadId>
dws aiapp modify --prompt "新描述" --thread-id <threadId> --skills s1,s2
Flags:
--prompt string 新的 prompt (必填)
--skills string 技能 ID 列表,逗号分隔
--thread-id string threadId (必填)
```
## 三个动作的关系
`aiapp` 的三个动作不是彼此独立的,它们描述的是同一个应用任务的生命周期:
1. `create` 用来发起一次新的应用生成任务,返回 `taskId` 和 `threadId`
2. `query` 用来跟踪这次任务是否完成,以及读取生成结果
3. `modify` 用来基于同一个 `threadId` 继续和已生成的应用对话,本质上是对已有应用做增量修改
可以把它理解为:
- `create` = 新建一个应用会话
- `query` = 查看这个会话对应任务的执行状态和结果
- `modify` = 在这个会话里继续追加新的需求,让同一个应用继续演化
关键标识符的关系:
- `taskId` 表示某一次具体的执行任务,主要给 `query` 使用
- `threadId` 表示这个应用的持续会话,主要给 `modify` 使用
- 一次 `create` 会产生一个新的 `taskId`,同时也会产出或确认一个 `threadId`
- 后续每次 `modify` 都是在同一个 `threadId` 上发起新的任务,因此也会产生新的 `taskId`
因此推荐把它们看成一条闭环链路,而不是三个平级命令:
`create -> query -> modify -> query -> modify ...`
## `attachments` 的含义
`attachments` 是 `aiapp` 的输入材料参数,用来把文件和当前任务一起提交给 `aiapp`。
它适合这些场景:
- 根据 Excel 创建应用
- 根据需求文档、表格、截图继续完善应用
- 在创建应用时补充新的结构化材料
可以把它理解为:
- `prompt` 描述“希望系统做什么”
- `attachments` 提供“系统可直接参考的文件材料”
### 正确心智
- 当前 CLI 中,`attachments` 是 `create` 的补充输入,不是独立动作
- 即使提供了附件,主动作仍然是 `aiapp create`
- 当用户说“按这个 Excel 建一个应用”时,优先走 `aiapp`,并把 Excel 作为 `attachments` 传入
### 数据结构
`attachments` 不是单个字符串,而是对象数组。每个对象当前按以下结构传递:
```json
[
{
"name": "warehouse.xlsx",
"type": "excel",
"url": "https://tmp/warehouse.xlsx",
"size": 102400
}
]
```
每个附件对象当前可用字段:
- `name`:文件名
- `type`:文件类型或 MIME 类型
- `url`:可直接访问的临时下载地址
- `size`:文件大小,可选
如果要传多个附件,就在数组中放多个对象。
### Excel 场景
如果用户上传了 Excel,通常应该:
- 在 `prompt` 里明确写“根据附件中的 Excel 创建应用”
- 同时把 Excel 通过 `attachments` 传给 `aiapp`
这类需求都应优先视为 `aiapp`:
- “根据这个 Excel 建一个库存管理应用”
- “把附件里的表格做成一个 CRM 应用”
- “按这个 Excel 生成一个新应用”
### 使用规则
- `create` 可以带图片、Excel 等附件
- 当前 `modify` 不支持 `--attachments` 参数;需要结合新材料修改应用时,把材料内容概括进 `prompt`
- `query` 不使用 `attachments`
- 附件应按对象数组传递,而不是传单个 ID 或随意文本
- 如果用户提供本地文件并要创建应用 → 先上传文件获取 URL,再执行 `aiapp create`
- 每个附件对象通常至少应包含可访问的临时下载地址 `url`
- 如果有附件但没有特别完整的 prompt,也仍然建议补一句明确意图,避免模型误判
### 示例
```bash
# 根据 Excel 创建应用
dws aiapp create --prompt "根据附件里的 Excel 创建一个仓库管理应用" \
--attachments '[{"name":"warehouse.xlsx","type":"excel","url":"https://tmp/warehouse.xlsx"}]' \
--format json
# 创建应用时结合图片材料
dws aiapp create --prompt "根据附件图片创建一个首页风格参考应用" \
--attachments '[{"name":"homepage.png","type":"image/png","url":"https://tmp/homepage.png"}]' \
--format json
```
## `--skills` 的含义
`--skills` 是 `aiapp` 的附加能力参数,不是一个独立动作。
它的作用不是“先执行这些技能,再单独执行 `aiapp`”,而是:
- 在 `create` 时,把这些官方技能作为本次应用生成任务的附加约束或附加能力一起传入
- 在 `modify` 时,把这些官方技能作为本次修改任务的附加约束或附加能力一起传入
可以把它理解为:
- `prompt` 描述“你想做什么应用”
- `--skills` 描述“这次生成或修改时,要额外挂载哪些官方能力或规范”
因此 `--skills` 必须依附于 `create` 或 `modify` 使用,本身不能单独完成创建应用。
### 正确心智
- `aiapp` 是主动作
- `--skills` 是主动作的附加参数
- 用户是否挂载官方技能,会影响应用生成或修改效果
- 即使用户提到了某个官方技能,最终仍然应该优先调用 `aiapp create` 或 `aiapp modify`
### 常见误区
不要把以下场景理解错:
- 用户说“创建一个应用,并遵循设计规范”
- 正确做法:调用 `aiapp create ... --skills <design-skill-id>`
- 不正确做法:把“设计规范技能”当成一个和 `aiapp` 平级、单独执行的动作
- 用户说“继续修改刚才那个应用,并带上设计规范”
- 正确做法:调用 `aiapp modify ... --thread-id <threadId> --skills <design-skill-id>`
- 不正确做法:先执行设计规范技能,再单独调用 `modify`
- 用户只是在描述“希望页面更漂亮、更标准”
- 如果已知对应的官方技能 ID,可以通过 `--skills` 传入
- 如果没有明确的技能 ID,不要编造,优先把需求写进 `prompt`
### 什么时候传 `--skills`
适合传 `--skills` 的情况:
- 用户明确要求遵循某个已知的官方能力、官方规范、官方模板
- 当前上下文里已经拿到了可用的官方技能 ID/Code
- 需要在创建或修改时显式挂载这些技能
不适合传 `--skills` 的情况:
- 没有明确的技能 ID
- 只是模糊地希望“更专业”“更好看”,但没有对应官方技能可挂
- 技能本身不是 `aiapp` 官方子技能,而是其他产品的独立操作能力
### 使用规则
- `create` 和 `modify` 都可以带 `--skills`
- `query` 不使用 `--skills`
- `--skills` 里的值必须是技能 ID,多个值用逗号分隔
- 不要猜测、编造技能 ID,必须来自已知上下文或系统返回
### 示例
```bash
# 创建应用,并挂载一个钉钉设计规范技能
dws aiapp create --prompt "创建一个仓库管理应用" \
--skills dingtalk_design_spec --format json
# 修改已有应用,并继续带上官方技能
dws aiapp modify --prompt "增加个库存大盘图表页" \
--thread-id <threadId> \
--skills dingtalk_design_spec --format json
```
## 意图判断
用户说以下任何一种,都走 `create`:
- “做个应用 / 创建应用 / 生成应用 / 搭个应用”
- “做个系统 / 平台 / 后台 / 工具 / 页面”
- “帮我做个计算器 / 仓库管理 / CRM / OA / 审批 / 报表 / 信息收集应用”
- “按这段需求生成一个钉钉应用”
用户说以下任何一种,都走 `query`:
- “查看应用状态”
- “这个应用创建好了没”
- “查一下 taskId 对应的进度”
- “继续轮询应用创建进展”
用户说以下任何一种,都走 `modify`:
- “修改应用”
- “更新刚才那个应用”
- “基于这个 thread/task 继续改”
- “把首页改一下 / 把功能补上 / 换个风格”
## 核心工作流
```bash
# 1. 创建 AI 应用 — 提取 taskId 和 threadId
dws aiapp create --prompt "创建一个天气查询应用" --format json
# 2. 查询应用状态
dws aiapp query --task-id <taskId> --format json
# 3. 修改应用
dws aiapp modify --prompt "新描述" --thread-id <threadId> \
--skills skill1,skill2 --format json
```
补充理解:
- 如果用户还没有现成应用,先走 `create`
- 如果用户刚发起了创建,通常先走 `query` 看是否完成
- 如果用户说“继续改刚才那个应用”,优先找上一次返回里的 `threadId`,走 `modify`
- `modify` 完成后,仍然可以继续用新的 `taskId` 走 `query`
- 不要把 `modify` 理解成“覆盖旧应用”,它更像是在同一个应用会话中追加新需求
- `attachments` 不改变 `create/query/modify` 的主流程,它只是给 `create` 增加文件输入
- 如果附件是 Excel,优先理解为 `create` 场景;`modify` 当前不要使用附件参数
- `--skills` 不改变 `create/query/modify` 的主流程,它只是给 `create` 或 `modify` 增加官方附加能力
推荐决策顺序:
1. 先判断用户是不是要“生成一个新的应用/系统”
2. 如果是,直接用 `aiapp create`
3. 如果用户是在已有应用基础上继续改,用 `aiapp modify`
4. 如果只是查进度或结果,用 `aiapp query`
## 查询与轮询预期
`aiapp` 的创建和修改通常不是秒级任务,而是分钟级任务。
- 简单应用通常需要几分钟
- 复杂应用可能持续十几分钟到几十分钟
因此在使用 `query` 时,不要假设“连续几次没有完成”就代表任务卡死。
### 正确的轮询策略
- 不要每 5-10 秒高频轮询
- 建议以 30 秒为默认轮询间隔
- 如果任务已经明确进入长流程,可进一步放宽到 60 秒
- 除非用户明确要求实时刷新,否则不要自行创建过于频繁的定时查询
### 代理行为约束
当 agent 已经帮用户发起了 `aiapp create` 或 `aiapp modify` 后,不要只查询 1-2 次就把后续进度甩给用户自己处理。
- 只要任务仍处于 `queued` 或 `running`,且用户没有明确要求停止,agent 应继续主动跟踪进度
- 如果当前会话无法持续等待,可以创建定时任务继续查询,但定时任务必须是“以拿到最终结果为目标”的,而不是无条件永久轮询
- 如果返回了 `threadViewUrl`,可以告诉用户“你也可以点这个链接查看实时进度”,但这不能替代 agent 自己继续追踪任务
### 定时任务规则
如果 agent 选择创建定时任务来继续查询 `aiapp` 状态,必须同时设置结束条件:
- 当 `status = succeeded` 且已拿到 `appPreviewUrl` 时,立即停止后续查询,并主动删除或关闭该定时任务
- 当 `status = failed` 时,立即停止后续查询,并主动删除或关闭该定时任务
- 不要在已经拿到目标结果后继续保留定时任务
- 不要创建“只会查询、不会自终止”的定时任务
换句话说,定时任务应当是“查询直到成功或失败,然后自动结束”的一次性追踪任务,而不是长期后台轮询
### 如何理解 `query` 返回
`query` 的返回里,除了总状态外,还可能包含进度信息,例如:
- 当前正在执行哪个步骤
- 已完成多少步骤
- 最近一次活动时间
- 当前步骤列表
- 当前 thread 的访问链接
因此判断任务是否还在推进时,不要只看 `status` 是否还是 `running`,还要结合以下字段一起看:
- `updatedAt` / `lastActivityAt`
- `progress.summary`
- `progress.currentStep`
- `progress.steps`
- `threadViewUrl`
只要这些字段仍在变化,通常就说明任务仍在正常推进。
### `threadViewUrl` 与 `appPreviewUrl` 的区别
`query` 或 `create` 的返回里,可能会有两个不同的链接:
- `threadViewUrl`:当前 thread 的访问链接。用于打开 `aiapp` 站点里的会话/生成进度页面,适合在应用还没生成完成时查看实时状态
- `appPreviewUrl`:最终生成出的应用访问地址。只有应用创建成功后才会出现,适合真正打开应用去使用
不要把这两个链接混用:
- 任务还在 `queued/running` 时,优先使用 `threadViewUrl`
- 任务 `succeeded` 且结果里已有 `appPreviewUrl` 时,再使用 `appPreviewUrl`
### 何时停止轮询
- 当 `status = succeeded` 时,停止轮询,并从结果中提取 `threadId`、应用链接等信息
- 当 `status = failed` 时,停止轮询,并向用户报告失败
- 当任务长时间保持 `running`,但 `lastActivityAt` 和 `progress` 都没有变化时,再考虑提示可能卡住,而不是过早下结论
- 如果此前为查询进度创建了定时任务,在 `succeeded` 或 `failed` 后要主动删除该定时任务,而不是留给用户处理
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `create` | `taskId`, `threadId`, `threadViewUrl` | query 的 --task-id, modify 的 --thread-id,或直接打开进度页 |
| `query` | `threadViewUrl`, `appPreviewUrl`, 应用详情 | 查看进度页,或在成功后打开应用预览并决定是否 modify |
## 注意事项
- `create` 通过自然语言 prompt 描述应用功能,系统自动生成
- 对“应用/系统/工具/后台/原型”类需求,优先使用 `aiapp`,不要改用其他产品命令模拟实现
- 对“根据附件 / 根据 Excel / 根据上传表格生成应用”这类需求,也优先使用 `aiapp`
- `attachments` 需要按对象数组传递,当前结构是 `[{name,type,url,size}]`
- `create` 可使用 Excel、图片等附件;`modify` 当前不支持附件参数
- `--skills` 传入官方技能 UID 列表,逗号分隔,可增强应用能力
- `modify` 需要 `--thread-id` 来标识修改哪个会话线程
- 应用未生成完成前,如果需要让用户查看实时状态,优先使用返回里的 `threadViewUrl`
- 应用生成成功后,如果需要真正打开应用,再使用 `appPreviewUrl`
- 创建后可通过 `query` 轮询 `taskId` 查看创建进度,但轮询间隔默认应为 20-30 秒,不要 10 秒级频繁轮询
- agent 不应只查 1-2 次就放弃;如果任务仍在运行,应继续跟踪,或创建带终止条件的定时任务继续跟踪
- 如果 agent 创建了用于查询进度的定时任务,在任务成功或失败后必须主动删除或结束该定时任务
- 不要直接使用用户本地文件路径作为附件 URL,必须先上传转换为互联网可访问链接
## 自动化脚本
| 脚本 | 场景 | 用法 |
|------|------|------|
| [aiapp_create_and_poll.py](../../scripts/aiapp_create_and_poll.py) | 一键创建 AI 应用并自动轮询进度直到完成 | `python aiapp_create_and_poll.py --prompt "创建一个天气查询应用"` |
@@ -1,138 +0,0 @@
#!/usr/bin/env python3
"""
创建 AI 应用并自动轮询等待完成
用法:
python aiapp_create_and_poll.py \
--prompt "创建一个仓库管理应用"
python aiapp_create_and_poll.py \
--prompt "生成客户管理 CRM" \
--skills skill1,skill2 \
--interval 30 \
--timeout 600
python aiapp_create_and_poll.py --dry-run --prompt "test"
"""
import sys
import json
import subprocess
import argparse
import time
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return {'dry_run': True}
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=120
)
if result.returncode != 0:
print(f" ✗ 错误:{result.stderr.strip()}")
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f" ✗ 错误:{e}")
return None
def main():
parser = argparse.ArgumentParser(
description='创建 AI 应用并轮询等待完成'
)
parser.add_argument(
'--prompt', required=True, help='应用描述'
)
parser.add_argument(
'--skills', default='', help='技能 ID 列表'
)
parser.add_argument(
'--interval', type=int, default=30,
help='轮询间隔秒 (默认 30)',
)
parser.add_argument(
'--timeout', type=int, default=600,
help='最大等待秒 (默认 600)',
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
print(f'🚀 创建 AI 应用...')
print(f' Prompt: {args.prompt}')
cmd_args = [
'aiapp', 'create',
'--prompt', args.prompt,
'--format', 'json',
]
if args.skills:
cmd_args.extend(['--skills', args.skills])
create_data = run_dws(cmd_args, dry_run=args.dry_run)
if args.dry_run:
run_dws([
'aiapp', 'query',
'--task-id', '<TASK_ID>',
'--format', 'json',
], dry_run=True)
return
if not create_data:
sys.exit(1)
task_id = create_data.get('taskId') or create_data.get('id', '')
thread_id = create_data.get('threadId', '')
print(f" ✓ 任务已创建")
print(f" taskId: {task_id}")
print(f" threadId: {thread_id}")
print(f'\n⏳ 轮询等待 (间隔 {args.interval}s, '
f'超时 {args.timeout}s)...')
elapsed = 0
while elapsed < args.timeout:
time.sleep(args.interval)
elapsed += args.interval
query_data = run_dws([
'aiapp', 'query',
'--task-id', task_id,
'--format', 'json',
])
if not query_data:
print(f" [{elapsed}s] ⚠ 查询失败,继续等待...")
continue
status = (query_data.get('status')
or query_data.get('state', 'unknown'))
progress = query_data.get('progress', {})
step = ''
if isinstance(progress, dict):
step = progress.get('currentStep', '')
if status == 'succeeded':
print(f" [{elapsed}s] ✅ 应用创建成功!")
if thread_id:
print(f" threadId: {thread_id}")
return
elif status == 'failed':
print(f" [{elapsed}s] ❌ 创建失败")
sys.exit(1)
else:
info = f" [{elapsed}s] ⏳ {status}"
if step:
info += f" ({step})"
print(info)
print(f"\n⏰ 超时 ({args.timeout}s),任务可能仍在运行")
print(f" 可手动查询: dws aiapp query --task-id {task_id}")
if __name__ == '__main__':
main()
+38 -19
View File
@@ -338,35 +338,54 @@ Flags:
### message (会话消息管理)
#### 拉取会话消息内容 — 拉取指定群聊或单聊的会话消息内容
#### 拉取群聊会话消息内容 — 拉取指定群聊的会话消息内容(仅群聊)
--group 指定群聊,--user 指定单聊用户(通过 userId),--open-dingtalk-id 指定单聊用户(通过 openDingTalkId),三者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
--group 指定群聊 openConversationId(**本命令仅支持群聊**;拉取单聊/私聊消息请改用 `chat message list-direct`)。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
```
Usage:
dws chat message list [flags]
Example:
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00"
dws chat message list --user <userId> --time "2025-03-01 00:00:00" --limit 50
dws chat message list --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 50
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --limit 50
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --forward=false
# 拉单聊改用 list-direct: dws chat message list-direct --user <userId> --time "2025-03-01 00:00:00"
Flags:
--forward true=拉给定时间之后的消息,false=拉给定时间之前的消息 (default true)
--group string 群聊 openconversation_id(群聊时必填)
--group string 群聊 openconversation_id(必填,**仅支持群聊**;查单聊用 chat message list-direct --user <userId>)
--limit int 返回数量,不传则不限制
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
--user string 单聊用户 userId(单聊时与 --open-dingtalk-id 二选一)
--open-dingtalk-id string 单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景)
注意:
- --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id
- --user 和 --open-dingtalk-id 都是发起单聊消息拉取,区别在于用不同格式的用户标识:
- --user 传 userId(企业内部应用常用)
- --open-dingtalk-id 传 openDingTalkId(三方应用或跨组织场景常用,无法获取 userId 时使用)
- 本命令**仅支持群聊**,必须指定 --group;拉取单聊(私聊)消息请改用 `chat message list-direct`(旧版的 `list --user` / `list --open-dingtalk-id` 已不再支持)
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- 翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time
- 话题圈消息拉取流程:如果返回的会话消息中包含 openConvThreadId 字段,说明是话题类消息。要获取完整的话题内容,需要两步操作:(1) 先通过 dws chat message list 拉取话题主消息(即话题帖子本身);(2) 再调用 dws chat message list-topic-replies --group <openConversationId> --topic-id <openConvThreadId> 分页拉取该话题下的所有回复消息。只有话题主消息 + 回复列表合在一起,才是一条话题的完整内容。
```
#### 拉取单聊消息内容 — 按对方 userId 拉取与某同事的单聊(私聊)历史消息
按对方 userId(或 openDingTalkId)拉取与该同事的单聊会话消息,**专用于私聊**;查群聊请用 `chat message list --group`。同组织内同事用 --user,非同组织好友用 --open-dingtalk-id,二者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
```
Usage:
dws chat message list-direct [flags]
Example:
dws chat message list-direct --user <对方userId> --time "2025-03-01 00:00:00" --limit 50
dws chat message list-direct --user <对方userId> --time "2025-03-01 00:00:00" --forward=false
dws chat message list-direct --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 20
# 查询对方 userId: dws contact user search --keyword "姓名" 或 dws aisearch person --keyword "姓名" --dimension name
Flags:
--forward true=从老往新(给定时间之后),false=从新往老(给定时间之前) (default true)
--user string 对方 userId(同组织内同事,与 --open-dingtalk-id 二选一)
--open-dingtalk-id string 对方 openDingTalkId(非同组织普通好友场景,与 --user 二选一)
--limit int 每页返回数量(默认 50)
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
注意:
- --user 与 --open-dingtalk-id 二选一,必须且只能指定其一;同组织同事优先用 --user
- --time 必填;翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time
- 本命令是 `chat message list` 拆分出的单聊专用命令;查群聊消息请用 `chat message list --group`
```
#### 以当前用户身份发送消息 — --group 群聊 / --user 或 --open-dingtalk-id 单聊
**重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。**
@@ -592,7 +611,7 @@ Flags:
注意:
- 四个参数每次请求都会传递给服务端,cursor 首页传 "0"
- 与 chat message list 的区别:list 拉取指定单个会话(群聊或单聊)的消息,list-all 拉取当前用户所有会话的消息
- 与 chat message list 的区别:list 拉取指定群聊会话的消息(单聊用 list-direct),list-all 拉取当前用户所有会话的消息
- 翻页:hasMore=true 时,用响应中的 nextCursor 值作为下次 --cursor 参数继续翻页
- 时间格式统一为 yyyy-MM-dd HH:mm:ss
```
@@ -1147,7 +1166,7 @@ Flags:
用户说"我特别关注的人最近发了什么消息/关注的人最近聊了啥/星标联系人最近的动态" → `chat message list-focused`(零参数一行命令)
用户说"某人发给我的消息/指定发送者的消息/某人最近的消息" → `chat message list-by-sender --sender-user-id <userId>` 或 `--sender-open-dingtalk-id <openDingTalkId>`(跨单聊+群聊)
用户说"和某人的单聊聊天记录/拉某人单聊历史" → `chat message list --user <userId>` 或 `--open-dingtalk-id <openDingTalkId>`
用户说"和某人的单聊聊天记录/拉某人单聊历史" → `chat message list-direct --user <userId>` 或 `--open-dingtalk-id <openDingTalkId>`
用户说"某个群的聊天记录" → `chat message list --group <openConversationId>`
用户说"我最近所有消息/我今天的消息" → `chat message list-all --start <ISO> --end <ISO>`
用户说"@我的消息/提及我的" → `chat message list-mentions --start <ISO> --end <ISO>`
@@ -1167,7 +1186,7 @@ Flags:
用户说"改群名" → `chat group rename`
用户说"聊天记录/会话消息/拉取会话" → `chat message list`
用户说"某人发给我的消息/指定发送者/某人的消息" → `chat message list-by-sender`(用户未明确说"单聊"时优先使用,跨单聊/群聊)
用户说"拉取和某人的单聊记录/单聊消息" → `chat message list --user`(用户明确说"单聊"时使用)
用户说"拉取和某人的单聊记录/单聊消息" → `chat message list-direct --user`(单聊专用;用户明确说"单聊"时使用)
用户说"@我的消息/at我的/提及我的" → `chat message list-mentions`
用户说"未读消息会话/未读会话列表/我的未读会话" → `chat message list-unread-conversations`
用户说"发群消息(以个人身份)" → `chat message send --group`
@@ -1222,8 +1241,8 @@ Flags:
关键区分:
- `chat search` — 搜**群/会话名**返回 `openConversationId`,**不**搜消息内容;要搜消息内容请用 `chat message search-advanced`(首选)/ `chat message search` / `list-by-sender` / `list-all`,**勿混淆**
- `chat message list` — 拉取指定会话的消息(需指定 --group 或 --user),按时间点 + 方向翻页
- `chat message list --user` — list 的单聊模式,拉取与指定用户的单聊记录(用户明确说"单聊""私聊"时使用)
- `chat message list` — 拉取指定**群聊**的消息(需指定 --group,**仅群聊**),按时间点 + 方向翻页
- `chat message list-direct` — 单聊专用,拉取与指定用户的单聊(私聊)记录(--user / --open-dingtalk-id;用户明确说"单聊""私聊"时使用)
- `chat message list-by-sender` — 搜索指定发送者发给我的消息,跨所有会话(单聊+群聊均包含,用户只说"某人发的消息"时优先使用)
- `chat message list-mentions` — 拉取 @我 的消息(跨单聊/群聊,可选指定群)
- `chat message list-unread-conversations` — 拉取当前用户存在未读消息的会话列表(可选 `--count`)
@@ -1448,8 +1467,8 @@ Flags:
| `chat search` | `openConversationId` | message send/list、group members 等的 --group |
| `chat group create` | `openConversationId` | 同上 |
| `chat message list-all` | `nextCursor` | 下次 list-all 的 --cursor |
| `aisearch person` | `userId` | message send 的 --user、send-by-bot 的 --users、send-by-bot 的 --at-user-ids、list-by-sender 的 --sender-user-id |
| `aisearch person` → `contact user get` | `openDingTalkId` | message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、send-by-bot 的 --open-dingtalk-ids、send-by-bot 的 --at-open-dingtalk-ids、list-by-sender 的 --sender-open-dingtalk-id、message list 的 --open-dingtalk-id |
| `aisearch person` | `userId` | message send 的 --user、list-direct 的 --user、send-by-bot 的 --users、send-by-bot 的 --at-user-ids、list-by-sender 的 --sender-user-id |
| `aisearch person` → `contact user get` | `openDingTalkId` | message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、list-direct 的 --open-dingtalk-id、send-by-bot 的 --open-dingtalk-ids、send-by-bot 的 --at-open-dingtalk-ids、list-by-sender 的 --sender-open-dingtalk-id |
| `chat bot search` | `robotCode` | send-by-bot / recall-by-bot 的 --robot-code(仅我创建的机器人,无 openDingTalkId) |
| `chat bot find` | `openDingTalkId` | 给机器人发单聊消息(全部可用机器人,额外返回 openDingTalkId) |
| `chat message send-by-bot` | `processQueryKey` | recall-by-bot 的 --keys |
@@ -1484,7 +1503,7 @@ Flags:
- `--group` 为群聊会话 ID (openconversation_id),可从群搜索或群聊信息中获取
- `chat message send` 的 text 是位置参数(恰好 1 个),非 flag;群聊用 `--group`,单聊用 `--user`(userId)或 `--open-dingtalk-id`(openDingTalkId),三者互斥;纯文本/Markdown 单聊传 `--user` 时直接走 userId 发送能力;`--at-all`、`--at-open-dingtalk-ids` 仅在 `--group` 群聊时生效;富媒体消息通过 `--msg-type` 指定类型(image/file),必须显式指定;发送文件/媒体消息时,必须先根据文件扩展名判断 msgType:图片→image,其他所有→file,不可跳过此判断
- `chat message list-all` 的四个参数(--start、--end、--limit、--cursor)每次请求都必须传递;翻页时用响应中的 nextCursor 值作为下次 --cursor
- `chat message list` 的 `--group`、`--user`、`--open-dingtalk-id` 三者互斥,必须且只能指定其一
- `chat message list` **仅支持群聊**,必须指定 `--group`;拉取单聊用 `chat message list-direct`(`--user` / `--open-dingtalk-id` 二选一)
- `chat message list-by-sender` 不需要指定单聊/群聊,返回结果自带会话类型标识;`--sender-user-id`(userId)与 `--sender-open-dingtalk-id`(openDingTalkId)二选一;时间用 `--start`/`--end`(ISO-8601),分页用 `--limit`/`--cursor`
- `chat message list-mentions` 可选 `--group` 指定群聊,不传则查全部;时间用 `--start`/`--end`(ISO-8601),分页用 `--limit`/`--cursor`
- `chat message list-unread-conversations` 获取当前用户未读会话列表,可选 `--count` 指定返回条数
@@ -9,7 +9,7 @@
工作流:
1. 通过 --name 搜索通讯录,获取 userId(或直接传 --user)
2. 调用 chat message list --user <userId> 拉取单聊消息
2. 调用 chat message list-direct --user <userId> 拉取单聊消息
3. 输出到终端或导出为 JSON 文件
"""
@@ -116,7 +116,7 @@ def main():
while page < max_pages and remaining > 0:
cmd_args = [
'chat', 'message', 'list',
'chat', 'message', 'list-direct',
'--user', user_id or '<USER_ID>',
'--time', current_time,
'--format', 'json',
@@ -139,8 +139,12 @@ def main():
page_msgs = data
has_more = False
else:
page_msgs = data.get('messages', data.get('result', []))
has_more = data.get('hasMore', False)
# list-direct 返回 {result: {hasMore, messages: [...]}},先解包 result
result_obj = data.get('result', data)
if not isinstance(result_obj, dict):
result_obj = data
page_msgs = result_obj.get('messages', result_obj.get('records', []))
has_more = result_obj.get('hasMore', data.get('hasMore', False))
if not page_msgs:
break
@@ -153,7 +157,8 @@ def main():
break
last_msg = page_msgs[-1]
boundary_time = last_msg.get('createAt') or last_msg.get('time', '')
boundary_time = (last_msg.get('createTime') or last_msg.get('createAt')
or last_msg.get('time', ''))
if not boundary_time or boundary_time == current_time:
break
current_time = boundary_time
@@ -183,9 +188,11 @@ def main():
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
else:
for m in all_messages:
if not isinstance(m, dict):
continue
sender = m.get('senderNick') or m.get('sender', '未知')
text = m.get('text') or m.get('content', '')
time_str = m.get('createAt') or m.get('time', '')
time_str = m.get('createTime') or m.get('createAt') or m.get('time', '')
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
+3 -3
View File
@@ -17,11 +17,11 @@ func TestMain(m *testing.M) {
absFixture, _ := filepath.Abs("testdata/empty_catalog.json")
os.Setenv(cli.CatalogFixtureEnv, absFixture)
// Serve the local servers.json fixture at /cli/discovery/apis so that
// Serve the local servers.json fixture at /cli/discovery/apis/bamboo so that
// the dynamic command generator can build CLI commands without network
// access. FetchServers calls {baseURL}/cli/discovery/apis.
// access. FetchServers calls {baseURL}/cli/discovery/apis/bamboo.
mux := http.NewServeMux()
mux.HandleFunc("/cli/discovery/apis", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/cli/discovery/apis/bamboo", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "testdata/servers.json")
})
srv := httptest.NewServer(mux)
+3 -3
View File
@@ -17,11 +17,11 @@ func TestMain(m *testing.M) {
absFixture, _ := filepath.Abs("testdata/empty_catalog.json")
os.Setenv(cli.CatalogFixtureEnv, absFixture)
// Serve the local servers.json fixture at /cli/discovery/apis so that
// Serve the local servers.json fixture at /cli/discovery/apis/bamboo so that
// the dynamic command generator can build CLI commands without network
// access. FetchServers calls {baseURL}/cli/discovery/apis.
// access. FetchServers calls {baseURL}/cli/discovery/apis/bamboo.
mux := http.NewServeMux()
mux.HandleFunc("/cli/discovery/apis", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/cli/discovery/apis/bamboo", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "testdata/servers.json")
})
srv := httptest.NewServer(mux)
+1 -1
View File
@@ -361,7 +361,7 @@ func newDocsMCPGateway(t *testing.T, expectations []docsServerExpectation) *http
mux := http.NewServeMux()
server := httptest.NewServer(mux)
mux.HandleFunc("/cli/discovery/apis", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/cli/discovery/apis/bamboo", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
@@ -237,7 +237,7 @@ func newRuntimeMarketServer(t *testing.T, fixture mockmcp.Fixture) *httptest.Ser
mux := http.NewServeMux()
srv := httptest.NewServer(mux)
mux.HandleFunc("/cli/discovery/apis", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/cli/discovery/apis/bamboo", func(w http.ResponseWriter, r *http.Request) {
response := market.ListResponse{}
for _, server := range fixture.Servers {
response.Servers = append(response.Servers, market.ServerEnvelope{
@@ -134,7 +134,7 @@ func newEvolvingRuntimeMarketServer(t *testing.T, fixture mockmcp.Fixture) *evol
s.SetFixture(t, fixture)
mux := http.NewServeMux()
mux.HandleFunc("/cli/discovery/apis", s.serveMarketServers)
mux.HandleFunc("/cli/discovery/apis/bamboo", s.serveMarketServers)
mux.HandleFunc("/mcp/market/detail", s.serveMarketDetail)
mux.HandleFunc("/", s.serveMCP)
s.server = httptest.NewServer(mux)
+1 -1
View File
@@ -20,7 +20,7 @@ func TestEndToEndDiscoveryFlow(t *testing.T) {
server := httptest.NewServer(mux)
defer server.Close()
mux.HandleFunc("/cli/discovery/apis", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/cli/discovery/apis/bamboo", func(w http.ResponseWriter, r *http.Request) {
payload := market.ListResponse{
Metadata: market.ListMetadata{Count: 1},
Servers: []market.ServerEnvelope{
+2 -2
View File
@@ -24,7 +24,7 @@ func NewServer(fixture Fixture) (*Server, error) {
s := &Server{Fixture: fixture}
mux := http.NewServeMux()
mux.HandleFunc("/cli/discovery/apis", s.serveMarketServers)
mux.HandleFunc("/cli/discovery/apis/bamboo", s.serveMarketServers)
mux.HandleFunc("/mcp/market/detail", s.serveMarketDetail)
mux.HandleFunc("/", s.serveMCP)
@@ -45,7 +45,7 @@ func DefaultServer() *Server {
}
func (s *Server) MarketURL() string {
return s.URL + "/cli/discovery/apis"
return s.URL + "/cli/discovery/apis/bamboo"
}
func (s *Server) DetailURL(mcpID int) string {
-1
View File
@@ -315,7 +315,6 @@ def run_test(test_case: TestCase) -> dict:
def get_reference_file(product: str) -> str:
"""Get the reference file for a product"""
product_to_file = {
'aiapp': 'aiapp.md',
'aitable': 'aitable.md',
'attendance': 'attendance.md',
'calendar': 'calendar.md',
+1 -1
View File
@@ -210,7 +210,7 @@ func TestDiscoverAllRuntimeKeepsWorkingServersWhenOneFails(t *testing.T) {
server := httptest.NewServer(mux)
defer server.Close()
mux.HandleFunc("/cli/discovery/apis", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/cli/discovery/apis/bamboo", func(w http.ResponseWriter, r *http.Request) {
payload := market.ListResponse{
Metadata: market.ListMetadata{Count: 2},
Servers: []market.ServerEnvelope{