diff --git a/internal/compat/chat_hooks.go b/internal/compat/chat_hooks.go new file mode 100644 index 00000000..e0f873ff --- /dev/null +++ b/internal/compat/chat_hooks.go @@ -0,0 +1,208 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// chat_hooks.go — CLI-side post-processing for the `chat` product's message +// list commands. The upstream API returns messages where createTime >= T when +// forward=true (inclusive boundary), causing infinite pagination loops when +// the caller uses the last message's createTime as the next page's --time. +// This hook detects forward=true, deduplicates boundary messages whose +// createTime matches the --time anchor, and emits a stderr warning recommending +// forward=false pagination. See https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/issues/430. + +package compat + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" +) + +// chatMessageListTools lists every chat toolName whose response contains a +// "messages" array subject to the forward=true boundary overlap. +var chatMessageListTools = map[string]bool{ + "list_conversation_message_v2": true, + "list_direct_conversation_message_v2": true, +} + +// installChatHook wires chat-specific RunE post-processing onto leaf commands +// emitted by BuildDynamicCommands. It is a no-op for non-chat products and +// for chat tools that do not need boundary dedup. +// +// The hook wraps the existing RunE (installed by NewDirectCommand) to capture +// JSON output, remove duplicate boundary messages when forward=true, and emit +// a pagination hint to stderr. +func installChatHook(cmd *cobra.Command, canonicalProduct, toolName string) { + if cmd == nil { + return + } + if strings.TrimSpace(canonicalProduct) != "chat" { + return + } + if !chatMessageListTools[toolName] { + return + } + wrapRunEForForwardDedup(cmd) +} + +// wrapRunEForForwardDedup wraps the command's RunE to intercept JSON output +// when --forward=true, deduplicate boundary messages, and emit a warning. +func wrapRunEForForwardDedup(cmd *cobra.Command) { + original := cmd.RunE + if original == nil { + return + } + cmd.RunE = func(c *cobra.Command, args []string) error { + forward, _ := c.Flags().GetBool("forward") + if !forward { + return original(c, args) + } + + timeAnchor, _ := c.Flags().GetString("time") + + origOut := c.OutOrStdout() + var buf bytes.Buffer + c.SetOut(&buf) + + runErr := original(c, args) + c.SetOut(origOut) + + if runErr != nil { + buf.WriteTo(origOut) + return runErr + } + + fmt.Fprint(c.ErrOrStderr(), + "⚠ forward=true 翻页时 API 返回 createTime >= T 的消息(含边界),"+ + "使用最后一条 createTime 作为下页 --time 会无限循环。"+ + "建议改用 --forward=false 从新往老翻页。\n") + + output := deduplicateForwardBoundary(buf.Bytes(), timeAnchor) + _, writeErr := origOut.Write(output) + return writeErr + } +} + +// deduplicateForwardBoundary parses JSON output and removes messages whose +// createTime matches the anchor time, preventing infinite pagination loops +// when forward=true. Returns the original data unchanged when no duplicates +// are found or when parsing fails. +func deduplicateForwardBoundary(data []byte, anchorTime string) []byte { + if anchorTime == "" || len(data) == 0 { + return data + } + + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + return data + } + + messages := findMessagesArray(payload) + if messages == nil { + return data + } + + filtered := make([]any, 0, len(messages)) + removed := 0 + for _, msg := range messages { + m, ok := msg.(map[string]any) + if !ok { + filtered = append(filtered, msg) + continue + } + if messageTimeMatchesAnchor(m, anchorTime) { + removed++ + continue + } + filtered = append(filtered, msg) + } + + if removed == 0 { + return data + } + + setMessagesArray(payload, filtered) + result, err := json.Marshal(payload) + if err != nil { + return data + } + return result +} + +// findMessagesArray locates the "messages" array in the payload, handling +// both top-level and nested-under-"result" response shapes. +func findMessagesArray(payload map[string]any) []any { + if msgs, ok := payload["messages"].([]any); ok { + return msgs + } + if inner, ok := payload["result"].(map[string]any); ok { + if msgs, ok := inner["messages"].([]any); ok { + return msgs + } + } + return nil +} + +// setMessagesArray writes the filtered messages back to the same nesting level +// where findMessagesArray found them. +func setMessagesArray(payload map[string]any, msgs []any) { + if _, ok := payload["messages"]; ok { + payload["messages"] = msgs + return + } + if inner, ok := payload["result"].(map[string]any); ok { + if _, ok := inner["messages"]; ok { + inner["messages"] = msgs + } + } +} + +// messageTimeMatchesAnchor checks whether a message's createTime field +// matches the pagination anchor time. Handles both string and numeric +// (millisecond timestamp) createTime values. +func messageTimeMatchesAnchor(msg map[string]any, anchor string) bool { + ct, ok := msg["createTime"] + if !ok { + return false + } + switch v := ct.(type) { + case string: + return strings.TrimSpace(v) == strings.TrimSpace(anchor) + case float64: + anchorMs, err := parseTimeToMillis(anchor) + if err != nil { + return false + } + return int64(v) == anchorMs + } + return false +} + +// parseTimeToMillis converts a "yyyy-MM-dd HH:mm:ss" string to unix +// milliseconds in the local timezone, matching the --time flag format. +func parseTimeToMillis(s string) (int64, error) { + for _, layout := range []string{ + "2006-01-02 15:04:05", + time.RFC3339, + "2006-01-02T15:04:05", + "2006-01-02", + } { + if t, err := time.ParseInLocation(layout, strings.TrimSpace(s), time.Local); err == nil { + return t.UnixMilli(), nil + } + } + return 0, fmt.Errorf("cannot parse time: %q", s) +} diff --git a/internal/compat/chat_hooks_test.go b/internal/compat/chat_hooks_test.go new file mode 100644 index 00000000..2e655a6a --- /dev/null +++ b/internal/compat/chat_hooks_test.go @@ -0,0 +1,407 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package compat + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/spf13/cobra" +) + +// ── deduplicateForwardBoundary unit tests ───────────────────── + +func TestDeduplicateForwardBoundary_RemovesBoundaryMessages(t *testing.T) { + input := map[string]any{ + "hasMore": true, + "messages": []any{ + map[string]any{"openMessageId": "msg3", "createTime": "2026-05-22 10:00:00"}, + map[string]any{"openMessageId": "msg2", "createTime": "2026-05-22 09:26:59"}, + map[string]any{"openMessageId": "msg1", "createTime": "2026-05-22 09:26:59"}, + }, + } + data, _ := json.Marshal(input) + + result := deduplicateForwardBoundary(data, "2026-05-22 09:26:59") + + var parsed map[string]any + if err := json.Unmarshal(result, &parsed); err != nil { + t.Fatalf("result is not valid JSON: %v", err) + } + msgs := parsed["messages"].([]any) + if len(msgs) != 1 { + t.Fatalf("expected 1 message after dedup, got %d", len(msgs)) + } + if msgs[0].(map[string]any)["openMessageId"] != "msg3" { + t.Fatalf("expected msg3 to remain, got %v", msgs[0]) + } +} + +func TestDeduplicateForwardBoundary_NestedUnderResult(t *testing.T) { + input := map[string]any{ + "result": map[string]any{ + "hasMore": true, + "messages": []any{ + map[string]any{"openMessageId": "msg2", "createTime": "2026-05-22 09:26:59"}, + map[string]any{"openMessageId": "msg1", "createTime": "2026-05-22 09:00:00"}, + }, + }, + } + data, _ := json.Marshal(input) + + result := deduplicateForwardBoundary(data, "2026-05-22 09:26:59") + + var parsed map[string]any + json.Unmarshal(result, &parsed) + msgs := parsed["result"].(map[string]any)["messages"].([]any) + if len(msgs) != 1 { + t.Fatalf("expected 1 message after dedup, got %d", len(msgs)) + } + if msgs[0].(map[string]any)["openMessageId"] != "msg1" { + t.Fatalf("expected msg1 to remain, got %v", msgs[0]) + } +} + +func TestDeduplicateForwardBoundary_NoOverlap(t *testing.T) { + input := map[string]any{ + "hasMore": false, + "messages": []any{ + map[string]any{"openMessageId": "msg3", "createTime": "2026-05-22 10:00:00"}, + map[string]any{"openMessageId": "msg2", "createTime": "2026-05-22 09:30:00"}, + }, + } + data, _ := json.Marshal(input) + + result := deduplicateForwardBoundary(data, "2026-05-22 09:26:59") + + // No messages match anchor → returned unchanged + if string(result) != string(data) { + t.Fatal("expected data unchanged when no boundary overlap") + } +} + +func TestDeduplicateForwardBoundary_EmptyMessages(t *testing.T) { + input := map[string]any{ + "hasMore": false, + "messages": []any{}, + } + data, _ := json.Marshal(input) + + result := deduplicateForwardBoundary(data, "2026-05-22 09:26:59") + if string(result) != string(data) { + t.Fatal("expected data unchanged for empty messages") + } +} + +func TestDeduplicateForwardBoundary_EmptyAnchor(t *testing.T) { + data := []byte(`{"messages":[{"openMessageId":"msg1","createTime":"2026-05-22 09:26:59"}]}`) + result := deduplicateForwardBoundary(data, "") + if string(result) != string(data) { + t.Fatal("expected data unchanged when anchor is empty") + } +} + +func TestDeduplicateForwardBoundary_InvalidJSON(t *testing.T) { + data := []byte(`not json`) + result := deduplicateForwardBoundary(data, "2026-05-22 09:26:59") + if string(result) != string(data) { + t.Fatal("expected data unchanged for invalid JSON") + } +} + +func TestDeduplicateForwardBoundary_NilData(t *testing.T) { + result := deduplicateForwardBoundary(nil, "2026-05-22 09:26:59") + if result != nil { + t.Fatal("expected nil for nil data") + } +} + +func TestDeduplicateForwardBoundary_NoMessagesKey(t *testing.T) { + data := []byte(`{"hasMore":true,"other":123}`) + result := deduplicateForwardBoundary(data, "2026-05-22 09:26:59") + if string(result) != string(data) { + t.Fatal("expected data unchanged when no messages key") + } +} + +func TestDeduplicateForwardBoundary_NumericCreateTime(t *testing.T) { + // 2026-05-22 09:26:59 local → some unix millis value + input := map[string]any{ + "messages": []any{ + map[string]any{"openMessageId": "msg2", "createTime": float64(1747876019000)}, + map[string]any{"openMessageId": "msg1", "createTime": float64(1747876000000)}, + }, + } + data, _ := json.Marshal(input) + + // The numeric comparison path requires matching exact millis. + // Parse the anchor to millis first to know the expected value. + anchorMs, err := parseTimeToMillis("2026-05-22 09:26:59") + if err != nil { + t.Skipf("cannot parse anchor time on this platform: %v", err) + } + + // Rebuild input with the correct millis + input["messages"] = []any{ + map[string]any{"openMessageId": "msg2", "createTime": float64(anchorMs)}, + map[string]any{"openMessageId": "msg1", "createTime": float64(anchorMs - 19000)}, + } + data, _ = json.Marshal(input) + + result := deduplicateForwardBoundary(data, "2026-05-22 09:26:59") + + var parsed map[string]any + json.Unmarshal(result, &parsed) + msgs := parsed["messages"].([]any) + if len(msgs) != 1 { + t.Fatalf("expected 1 message after numeric dedup, got %d", len(msgs)) + } +} + +// ── messageTimeMatchesAnchor unit tests ─────────────────────── + +func TestMessageTimeMatchesAnchor_StringMatch(t *testing.T) { + msg := map[string]any{"createTime": "2026-05-22 09:26:59"} + if !messageTimeMatchesAnchor(msg, "2026-05-22 09:26:59") { + t.Fatal("expected string match") + } +} + +func TestMessageTimeMatchesAnchor_StringMismatch(t *testing.T) { + msg := map[string]any{"createTime": "2026-05-22 09:30:00"} + if messageTimeMatchesAnchor(msg, "2026-05-22 09:26:59") { + t.Fatal("expected no match") + } +} + +func TestMessageTimeMatchesAnchor_NoCreateTime(t *testing.T) { + msg := map[string]any{"openMessageId": "msg1"} + if messageTimeMatchesAnchor(msg, "2026-05-22 09:26:59") { + t.Fatal("expected no match when createTime absent") + } +} + +func TestMessageTimeMatchesAnchor_WhitespaceTolerant(t *testing.T) { + msg := map[string]any{"createTime": " 2026-05-22 09:26:59 "} + if !messageTimeMatchesAnchor(msg, "2026-05-22 09:26:59") { + t.Fatal("expected match with whitespace trimming") + } +} + +// ── installChatHook composition tests ───────────────────────── + +func newChatMessageListStub() *cobra.Command { + cmd := &cobra.Command{Use: "list"} + cmd.Flags().Bool("forward", false, "") + cmd.Flags().String("time", "", "") + return cmd +} + +func TestInstallChatHook_NoOpForOtherProduct(t *testing.T) { + cmd := newChatMessageListStub() + originalCalled := false + cmd.RunE = func(*cobra.Command, []string) error { + originalCalled = true + return nil + } + installChatHook(cmd, "todo", "list_conversation_message_v2") + + var buf bytes.Buffer + cmd.SetOut(&buf) + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !originalCalled { + t.Fatal("original RunE should still run when hook skips") + } +} + +func TestInstallChatHook_NoOpForNonTargetTool(t *testing.T) { + cmd := newChatMessageListStub() + installChatHook(cmd, "chat", "send_personal_message") + + if cmd.RunE != nil { + // RunE was set by the stub; hook should not have wrapped it + // because the tool is not in chatMessageListTools. + t.Fatal("hook should not modify RunE for non-target tools") + } +} + +func TestInstallChatHook_TargetToolForwardFalse(t *testing.T) { + cmd := newChatMessageListStub() + _ = cmd.Flags().Set("forward", "false") + + runCalled := false + cmd.RunE = func(c *cobra.Command, args []string) error { + runCalled = true + var buf bytes.Buffer + c.SetOut(&buf) + // Simulate writing JSON output + json.NewEncoder(&buf).Encode(map[string]any{"messages": []any{}}) + buf.WriteTo(c.OutOrStdout()) + return nil + } + + installChatHook(cmd, "chat", "list_conversation_message_v2") + + var out bytes.Buffer + cmd.SetOut(&out) + var stderr bytes.Buffer + cmd.SetErr(&stderr) + + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !runCalled { + t.Fatal("original RunE should be called") + } + if stderr.Len() > 0 { + t.Fatalf("expected no warning for forward=false, got: %s", stderr.String()) + } +} + +func TestInstallChatHook_TargetToolForwardTrue(t *testing.T) { + cmd := newChatMessageListStub() + _ = cmd.Flags().Set("forward", "true") + _ = cmd.Flags().Set("time", "2026-05-22 09:26:59") + + cmd.RunE = func(c *cobra.Command, args []string) error { + payload := map[string]any{ + "hasMore": true, + "messages": []any{ + map[string]any{"openMessageId": "msg2", "createTime": "2026-05-22 09:26:59"}, + map[string]any{"openMessageId": "msg1", "createTime": "2026-05-22 09:00:00"}, + }, + } + return json.NewEncoder(c.OutOrStdout()).Encode(payload) + } + + installChatHook(cmd, "chat", "list_conversation_message_v2") + + var out bytes.Buffer + cmd.SetOut(&out) + var stderr bytes.Buffer + cmd.SetErr(&stderr) + + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("unexpected err: %v", err) + } + + // Check stderr warning + if !strings.Contains(stderr.String(), "forward=true") { + t.Fatalf("expected forward=true warning in stderr, got: %s", stderr.String()) + } + + // Check output is deduplicated + var parsed map[string]any + if err := json.Unmarshal(out.Bytes(), &parsed); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + msgs := parsed["messages"].([]any) + if len(msgs) != 1 { + t.Fatalf("expected 1 message after dedup, got %d", len(msgs)) + } + if msgs[0].(map[string]any)["openMessageId"] != "msg1" { + t.Fatalf("expected msg1 to remain, got %v", msgs[0]) + } +} + +func TestInstallChatHook_ChainsExistingRunE(t *testing.T) { + cmd := newChatMessageListStub() + _ = cmd.Flags().Set("forward", "false") + originalCalled := false + cmd.RunE = func(*cobra.Command, []string) error { + originalCalled = true + return nil + } + installChatHook(cmd, "chat", "list_conversation_message_v2") + + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !originalCalled { + t.Fatal("original RunE was dropped") + } +} + +func TestInstallChatHook_NilCmdSafe(t *testing.T) { + installChatHook(nil, "chat", "list_conversation_message_v2") +} + +func TestInstallChatHook_ListDirectConversationTool(t *testing.T) { + cmd := newChatMessageListStub() + _ = cmd.Flags().Set("forward", "true") + _ = cmd.Flags().Set("time", "2026-05-22 09:26:59") + + cmd.RunE = func(c *cobra.Command, args []string) error { + payload := map[string]any{ + "messages": []any{ + map[string]any{"openMessageId": "msg1", "createTime": "2026-05-22 09:26:59"}, + }, + } + return json.NewEncoder(c.OutOrStdout()).Encode(payload) + } + + installChatHook(cmd, "chat", "list_direct_conversation_message_v2") + + var out bytes.Buffer + cmd.SetOut(&out) + var stderr bytes.Buffer + cmd.SetErr(&stderr) + + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("unexpected err: %v", err) + } + + var parsed map[string]any + json.Unmarshal(out.Bytes(), &parsed) + msgs := parsed["messages"].([]any) + if len(msgs) != 0 { + t.Fatalf("expected 0 messages after dedup, got %d", len(msgs)) + } +} + +func TestInstallChatHook_RunEPreservedOnError(t *testing.T) { + cmd := newChatMessageListStub() + _ = cmd.Flags().Set("forward", "true") + _ = cmd.Flags().Set("time", "2026-05-22 09:26:59") + + cmd.RunE = func(c *cobra.Command, args []string) error { + json.NewEncoder(c.OutOrStdout()).Encode(map[string]any{"error": "boom"}) + return apperrors.NewValidation("test error") + } + + installChatHook(cmd, "chat", "list_conversation_message_v2") + + var out bytes.Buffer + cmd.SetOut(&out) + var stderr bytes.Buffer + cmd.SetErr(&stderr) + + err := cmd.RunE(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "test error") { + t.Fatalf("expected original error to bubble, got %v", err) + } + // Output should still be written (passthrough on error) + if out.Len() == 0 { + t.Fatal("expected output to be written even on error") + } + // No warning on error path + if stderr.Len() > 0 { + t.Fatalf("expected no warning on error, got: %s", stderr.String()) + } +} diff --git a/internal/compat/dynamic_commands.go b/internal/compat/dynamic_commands.go index 2ca443fd..7edca275 100644 --- a/internal/compat/dynamic_commands.go +++ b/internal/compat/dynamic_commands.go @@ -239,6 +239,10 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run // todo_hooks.go for the full rationale). No-op for non-todo. installTodoHook(cmd, canonicalProduct, toolName) + // §chat-hook: post-processing for chat message list forward=true + // boundary dedup. No-op for non-chat. See chat_hooks.go (#430). + installChatHook(cmd, canonicalProduct, toolName) + // §1.4: Add to the right parent group attachToGroup(rootCmd, override.Group, groupCmds, cmd) } diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md index b52ed1d5..2f2c117a 100644 --- a/skills/mono/references/products/chat.md +++ b/skills/mono/references/products/chat.md @@ -358,7 +358,8 @@ Flags: 注意: - 本命令**仅支持群聊**,必须指定 --group;拉取单聊(私聊)消息请改用 `chat message list-direct`(旧版的 `list --user` / `list --open-dingtalk-id` 已不再支持) - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) - - 翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time + - **推荐翻页方式**:使用 `--forward=false`(从新往老翻),用结果中最后一条(最旧)的 createTime 作为下次 --time。返回数组始终按降序排列(最新在 `[0]`,最旧在 `[-1]`),翻页锚点取 `[-1]` 的 createTime。 + - **`--forward=true` 的已知限制**:API 返回 `createTime >= T` 的消息(含边界),用最后一条的 createTime 作为下页 --time 会返回完全相同的消息,导致无限循环。如需 forward=true,CLI 会自动去除与 --time 相同的边界消息并输出警告,但仍建议优先使用 forward=false。 - 话题圈消息拉取流程:如果返回的会话消息中包含 openConvThreadId 字段,说明是话题类消息。要获取完整的话题内容,需要两步操作:(1) 先通过 dws chat message list 拉取话题主消息(即话题帖子本身);(2) 再调用 dws chat message list-topic-replies --group --topic-id 分页拉取该话题下的所有回复消息。只有话题主消息 + 回复列表合在一起,才是一条话题的完整内容。 ``` @@ -382,7 +383,7 @@ Flags: 注意: - --user 与 --open-dingtalk-id 二选一,必须且只能指定其一;同组织同事优先用 --user - - --time 必填;翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time + - --time 必填;推荐使用 `--forward=false`(从新往老翻页)。`--forward=true` 存在边界重复导致无限循环的已知限制(详见 `chat message list` 注意事项)。 - 本命令是 `chat message list` 拆分出的单聊专用命令;查群聊消息请用 `chat message list --group` ``` diff --git a/skills/multi/dingtalk-chat/references/chat.md b/skills/multi/dingtalk-chat/references/chat.md index c30566e7..9fc52e4b 100644 --- a/skills/multi/dingtalk-chat/references/chat.md +++ b/skills/multi/dingtalk-chat/references/chat.md @@ -358,7 +358,8 @@ Flags: 注意: - 本命令**仅支持群聊**,必须指定 --group;拉取单聊(私聊)消息请改用 `chat message list-direct`(旧版的 `list --user` / `list --open-dingtalk-id` 已不再支持) - --group 的别名: --id, --chat, --conversation-id (均可替代 --group) - - 翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time + - **推荐翻页方式**:使用 `--forward=false`(从新往老翻),用结果中最后一条(最旧)的 createTime 作为下次 --time。返回数组始终按降序排列(最新在 `[0]`,最旧在 `[-1]`),翻页锚点取 `[-1]` 的 createTime。 + - **`--forward=true` 的已知限制**:API 返回 `createTime >= T` 的消息(含边界),用最后一条的 createTime 作为下页 --time 会返回完全相同的消息,导致无限循环。如需 forward=true,CLI 会自动去除与 --time 相同的边界消息并输出警告,但仍建议优先使用 forward=false。 - 话题圈消息拉取流程:如果返回的会话消息中包含 openConvThreadId 字段,说明是话题类消息。要获取完整的话题内容,需要两步操作:(1) 先通过 dws chat message list 拉取话题主消息(即话题帖子本身);(2) 再调用 dws chat message list-topic-replies --group --topic-id 分页拉取该话题下的所有回复消息。只有话题主消息 + 回复列表合在一起,才是一条话题的完整内容。 ``` @@ -382,7 +383,7 @@ Flags: 注意: - --user 与 --open-dingtalk-id 二选一,必须且只能指定其一;同组织同事优先用 --user - - --time 必填;翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time + - --time 必填;推荐使用 `--forward=false`(从新往老翻页)。`--forward=true` 存在边界重复导致无限循环的已知限制(详见 `chat message list` 注意事项)。 - 本命令是 `chat message list` 拆分出的单聊专用命令;查群聊消息请用 `chat message list --group` ```