Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7a3010b81 | ||
|
|
e7ef2c4677 | ||
|
|
8c2093a41a | ||
|
|
5fbf12fe50 | ||
|
|
dd419ca498 | ||
|
|
f826375556 | ||
|
|
cb95207d5a |
@@ -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
|
||||
|
||||
@@ -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
@@ -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 工具就能通过自然语言直接操作钉钉:
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>`(备选) |
|
||||
|
||||
@@ -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} 页)")
|
||||
|
||||
|
||||
@@ -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} 页)")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user