Compare commits

...
7 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
16 changed files with 289 additions and 73 deletions
+9
View File
@@ -6,6 +6,15 @@ 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
+3 -3
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/`, ... 19 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:
+3 -3
View File
@@ -71,9 +71,9 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
| 模式 | 安装内容 | 适合场景 |
|------|----------|----------|
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
| **multi** 🧪 **试验版 / Preview** | 19 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
| **multi** 🧪 **试验版 / Preview** | 18 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。19 个独立 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/` ... 共 19 个),每个 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 工具就能通过自然语言直接操作钉钉:
+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) {
+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)
+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 -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)
}
}
@@ -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>`(备选) |
+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` 指定返回条数
+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} 页)")
+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} 页)")