feat(chat): unify typed and shortcut message contracts

This commit is contained in:
前津
2026-08-10 17:39:40 +08:00
parent 9f60cdeef1
commit 27afa806ca
11 changed files with 479 additions and 172 deletions
+66 -7
View File
@@ -18,6 +18,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
@@ -25,6 +26,56 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func callProjectedChatMessages(cmd *cobra.Command, toolName string, args map[string]any, search bool) error {
if deps.Caller.DryRun() {
return callMCPToolOnServer("chat", toolName, args)
}
text, err := callMCPToolReturnTextOnServer(cmd.Context(), "chat", toolName, args)
if err != nil {
return err
}
data := map[string]any{}
if strings.TrimSpace(text) != "" {
if err := unmarshalJSONUseNumber(text, &data); err != nil {
deps.Out.PrintRaw(text)
return nil
}
}
items := chatmsg.ListMessageItems(data)
if search {
items = chatmsg.SearchMessageItems(data)
}
messages := make([]map[string]any, 0, len(items))
for _, item := range items {
projected := make(map[string]any, len(item)+8)
for key, value := range item {
projected[key] = value
}
for key, value := range chatmsg.ProjectMessageV1(item, true) {
projected[key] = value
}
if value, exists := item["openMessageId"]; exists {
projected["openMessageId"] = value
} else if value, exists := projected["messageId"]; exists {
projected["openMessageId"] = value
}
if value, exists := item["content"]; exists {
projected["content"] = value
} else if value, exists := projected["text"]; exists {
projected["content"] = value
}
messages = append(messages, projected)
}
payload := make(map[string]any, len(data)+1)
for key, value := range data {
payload[key] = value
}
payload["messages"] = messages
return writeCommandPayload(cmd, payload)
}
func resolveMessageForward(cmd *cobra.Command, defaultForward bool) (bool, error) {
forwardStr, _ := cmd.Flags().GetString("forward")
forward := forwardStr != "false"
@@ -1851,11 +1902,12 @@ func newChatCommand() *cobra.Command {
chatMessageListCmd := &cobra.Command{
Use: "list",
Short: "拉取会话消息内容",
Long: `拉取指定群聊或单聊的会话消息内容。--group 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。推荐使用 --direction newer/older 控制时间方向:newer 表示从给定时间往现在拉,older 表示从给定时间往以前拉。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。引用回复消息会返回 quotedMessage 引用上下文;被引用的原消息是合并转发或图片时,对应的类型与内容也会随引用上下文返回。如果返回的会话消息中包含 openConvThreadId 字段,说明是话题消息,可以调用 dws chat message list-topic-replies 拉取话题回复消息列表,openConvThreadId 作为 topic-id 参数。`,
Long: `拉取指定群聊或单聊的会话消息内容。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId 和 content。--group 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。推荐使用 --direction newer/older 控制时间方向:newer 表示从给定时间往现在拉,older 表示从给定时间往以前拉。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。引用回复消息会返回 quotedMessage 引用上下文;被引用的原消息是合并转发或图片时,对应的类型与内容也会随引用上下文返回。如果返回的会话消息中包含 openConvThreadId 字段,说明是话题消息,可以调用 dws chat message list-topic-replies 拉取话题回复消息列表,openConvThreadId 作为 topic-id 参数。`,
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" --direction older
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --jq '.messages[] | {messageId, text}'
# 查询群 ID: dws chat search --query "群名"
# 查询 userId: dws contact user search --query "姓名"`,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -1899,7 +1951,7 @@ func newChatCommand() *cobra.Command {
if v := chatIntFlagOrFallback(cmd, "limit", "size"); v > 0 {
toolArgs["limit"] = v
}
return callMCPTool("list_conversation_message_v2", toolArgs)
return callProjectedChatMessages(cmd, "list_conversation_message_v2", toolArgs, false)
}
toolArgs := map[string]any{
"time": timeVal,
@@ -1913,7 +1965,7 @@ func newChatCommand() *cobra.Command {
if v := chatIntFlagOrFallback(cmd, "limit", "size"); v > 0 {
toolArgs["limit"] = v
}
return callMCPTool("list_individual_chat_message", toolArgs)
return callProjectedChatMessages(cmd, "list_individual_chat_message", toolArgs, false)
},
}
DeclareLeafMetadata(chatMessageListCmd, LeafSpec{
@@ -1939,7 +1991,10 @@ func newChatCommand() *cobra.Command {
AgentSummary: "分页读取指定会话消息及其引用上下文",
UseWhen: []string{"用户明确指定某个会话,并要读取消息或追溯引用回复中的原消息上下文时"},
AvoidWhen: []string{"跨全部会话按时间查询时使用 chat message list-all"},
Examples: []string{"dws chat message list --group <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50"},
Examples: []string{
"dws chat message list --group <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50",
"dws chat message list --group <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50 --jq '.messages[] | {messageId, text}'",
},
},
Parameters: []contract.ParamDecl{
{Name: "direction", Property: "forward"},
@@ -3074,10 +3129,11 @@ func newChatCommand() *cobra.Command {
chatMessageSearchCmd := &cobra.Command{
Use: "search",
Short: "按关键词搜索消息",
Long: `在当前用户的会话中按关键词搜索消息。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。`,
Long: `在当前用户的会话中按关键词搜索消息。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId 和 content。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。`,
Example: ` dws chat message search --query "changefree" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0
dws chat message search --query "codereview" --group <openconversation_id> --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0
dws chat message search --query "链接" --start "2026-04-15T00:00:00+08:00" --end "2026-04-16T00:00:00+08:00" --limit 100 --cursor <nextCursor>
dws chat message search --query "发布计划" --start "2026-07-01T00:00:00+08:00" --end "2026-07-10T00:00:00+08:00" --jq '.messages[] | {messageId, text}'
# 查询群 ID: dws chat search --query "群名"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlagWithAliases(cmd, "query", "keyword"); err != nil {
@@ -3110,7 +3166,7 @@ func newChatCommand() *cobra.Command {
if groupID != "" {
toolArgs["openConversationId"] = groupID
}
return callMCPTool("search_messages_by_keyword", toolArgs)
return callProjectedChatMessages(cmd, "search_messages_by_keyword", toolArgs, true)
},
}
DeclareLeafMetadata(chatMessageSearchCmd, LeafSpec{
@@ -3136,7 +3192,10 @@ func newChatCommand() *cobra.Command {
AgentSummary: "按关键词和时间范围搜索消息",
UseWhen: []string{"需要用关键词查找消息且过滤条件较简单时"},
AvoidWhen: []string{"需要多会话、发送者或 @维度组合时使用 search-advanced"},
Examples: []string{"dws chat message search --query \"发布计划\" --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-10T00:00:00+08:00\""},
Examples: []string{
"dws chat message search --query \"发布计划\" --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-10T00:00:00+08:00\"",
"dws chat message search --query \"发布计划\" --start \"2026-07-01T00:00:00+08:00\" --end \"2026-07-10T00:00:00+08:00\" --jq '.messages[] | {messageId, text}'",
},
},
Parameters: []contract.ParamDecl{
{Name: "end", Property: "endTime"},
@@ -160,6 +160,7 @@ func executeChatChangedContract(t *testing.T, caller *chatChangedContractCaller,
InitDeps(caller)
deps.Out.w = io.Discard
cmd := newChatCommand()
installExampleGlobalFlags(cmd)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs(args)
@@ -35,6 +35,7 @@ type imReadResultCall struct {
type imReadResultCaller struct {
responses map[string]string
calls []imReadResultCall
dryRun bool
}
func (c *imReadResultCaller) CallTool(_ context.Context, productID, toolName string, _ map[string]any) (*edition.ToolResult, error) {
@@ -43,7 +44,7 @@ func (c *imReadResultCaller) CallTool(_ context.Context, productID, toolName str
}
func (*imReadResultCaller) Format() string { return "json" }
func (*imReadResultCaller) DryRun() bool { return false }
func (c *imReadResultCaller) DryRun() bool { return c.dryRun }
func (*imReadResultCaller) Fields() string { return "" }
func (*imReadResultCaller) JQ() string { return "" }
@@ -63,6 +64,8 @@ func executeIMReadCommand(t *testing.T, caller *imReadResultCaller, processArgs
os.Args = processArgs
root := build()
installExampleGlobalFlags(root)
root.SetOut(&stdout)
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs(args)
@@ -85,13 +88,14 @@ func requireSameJSON(t *testing.T, got, want string) {
}
}
func TestChatMessageListPreservesQuotedRichMessageContext(t *testing.T) {
func TestCrossPlatformCoverageChatMessageListProjectsStableFieldsAndPreservesLegacy(t *testing.T) {
payload := `{
"result": {
"messages": [
{"msgType":"reply","quotedMessage":{"msgType":"merged_forward","content":{"items":[{"text":"原消息"}]}}},
{"msgType":"reply","quotedMessage":{"msgType":"image","content":{"mediaId":"media-1"}}}
]
{"openMessageId":"msg-1","content":{"text":"回复正文"},"createTime":101,"msgType":"reply","quotedMessage":{"msgType":"merged_forward","content":{"items":[{"text":"原消息"}]}}},
{"openMessageId":"msg-2","content":{"text":"图片回复"},"createTime":102,"msgType":"reply","quotedMessage":{"msgType":"image","content":{"mediaId":"media-1"}}}
],
"hasMore": false
}
}`
caller := &imReadResultCaller{responses: map[string]string{"list_conversation_message_v2": payload}}
@@ -104,7 +108,212 @@ func TestChatMessageListPreservesQuotedRichMessageContext(t *testing.T) {
if len(caller.calls) != 1 || caller.calls[0] != (imReadResultCall{productID: "chat", toolName: "list_conversation_message_v2"}) {
t.Fatalf("calls = %#v, want chat/list_conversation_message_v2", caller.calls)
}
requireSameJSON(t, got, payload)
var result map[string]any
if err := json.Unmarshal([]byte(got), &result); err != nil {
t.Fatalf("decode command output: %v\noutput: %s", err, got)
}
if _, exists := result["contractVersion"]; exists {
t.Fatalf("typed list added an out-of-scope contract envelope: %#v", result)
}
messages, ok := result["messages"].([]any)
if !ok || len(messages) != 2 {
t.Fatalf("messages = %#v", result["messages"])
}
first, ok := messages[0].(map[string]any)
if !ok || first["messageId"] != "msg-1" || first["openMessageId"] != "msg-1" || first["text"] != "回复正文" {
t.Fatalf("first projected message = %#v", messages[0])
}
if _, ok := first["content"].(map[string]any); !ok {
t.Fatalf("legacy content not preserved: %#v", first)
}
quoted, ok := first["quotedMessage"].(map[string]any)
if !ok || quoted["messageType"] != "merged_forward" {
t.Fatalf("merged-forward quote = %#v", first["quotedMessage"])
}
second, ok := messages[1].(map[string]any)
if !ok {
t.Fatalf("second projected message = %#v", messages[1])
}
quoted, ok = second["quotedMessage"].(map[string]any)
if !ok || quoted["messageType"] != "image" {
t.Fatalf("image quote = %#v", second["quotedMessage"])
}
resources, ok := quoted["resourceRefs"].([]any)
if !ok || len(resources) != 1 {
t.Fatalf("image quote resources = %#v", quoted["resourceRefs"])
}
legacy, ok := result["result"].(map[string]any)
if !ok {
t.Fatalf("legacy result envelope missing: %#v", result["result"])
}
legacyMessages, ok := legacy["messages"].([]any)
if !ok || len(legacyMessages) != 2 {
t.Fatalf("legacy result.messages = %#v", legacy["messages"])
}
}
func TestCrossPlatformCoverageChatMessageSearchProjectsStableFieldsAndPreservesLegacy(t *testing.T) {
payload := `{
"result": {
"conversationMessagesList": [{
"openConversationId": "cid-1",
"title": "项目群",
"messages": [{"openMessageId":"msg-search-1","messageId":"legacy-conflict","content":{"richText":"发布计划"},"createTime":201}]
}],
"nextCursor": "cursor-2"
}
}`
caller := &imReadResultCaller{responses: map[string]string{"search_messages_by_keyword": payload}}
got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
"message", "search", "--query", "发布计划",
"--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-10T00:00:00+08:00")
if err != nil {
t.Fatalf("chat message search returned error: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0] != (imReadResultCall{productID: "chat", toolName: "search_messages_by_keyword"}) {
t.Fatalf("calls = %#v, want chat/search_messages_by_keyword", caller.calls)
}
var result map[string]any
if err := json.Unmarshal([]byte(got), &result); err != nil {
t.Fatalf("decode command output: %v\noutput: %s", err, got)
}
if _, exists := result["contractVersion"]; exists {
t.Fatalf("typed search added an out-of-scope contract envelope: %#v", result)
}
messages, ok := result["messages"].([]any)
if !ok || len(messages) != 1 {
t.Fatalf("messages = %#v", result["messages"])
}
message, ok := messages[0].(map[string]any)
if !ok || message["messageId"] != "msg-search-1" || message["openMessageId"] != "msg-search-1" ||
message["conversationId"] != "cid-1" || message["text"] != "发布计划" {
t.Fatalf("projected search message = %#v", messages[0])
}
if _, ok := message["content"].(map[string]any); !ok {
t.Fatalf("legacy content not preserved: %#v", message)
}
legacy, ok := result["result"].(map[string]any)
if !ok {
t.Fatalf("legacy result envelope missing: %#v", result["result"])
}
legacyGroups, ok := legacy["conversationMessagesList"].([]any)
if !ok || len(legacyGroups) != 1 {
t.Fatalf("legacy result.conversationMessagesList = %#v", legacy["conversationMessagesList"])
}
if legacy["nextCursor"] != "cursor-2" {
t.Fatalf("legacy result.nextCursor = %#v", legacy["nextCursor"])
}
}
func TestCrossPlatformCoverageChatMessageListPreservesNonJSONResponse(t *testing.T) {
const payload = "upstream temporarily returned plain text"
caller := &imReadResultCaller{responses: map[string]string{"list_conversation_message_v2": payload}}
got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
"message", "list", "--group", "cid-1", "--time", "2026-07-14 00:00:00")
if err != nil {
t.Fatalf("chat message list returned error: %v", err)
}
if got != payload+"\n" {
t.Fatalf("command output = %q, want raw payload", got)
}
}
func TestCrossPlatformCoverageChatMessageListPreservesTopLevelMessageFields(t *testing.T) {
payload := `{
"messages": [{
"openMessageId": "msg-top-1",
"content": {"text": "顶层正文"},
"msgType": "text",
"senderName": "张三",
"extensionField": {"source": "legacy"}
}, {
"messageId": "msg-top-2",
"text": "稳定正文"
}],
"count": 99,
"partial": true,
"failedCount": 1,
"failures": [{"stage":"legacy","error":"legacy failure"}],
"hasMore": false
}`
caller := &imReadResultCaller{responses: map[string]string{"list_conversation_message_v2": payload}}
got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
"message", "list", "--group", "cid-1", "--time", "2026-07-14 00:00:00")
if err != nil {
t.Fatalf("chat message list returned error: %v", err)
}
var result map[string]any
if err := json.Unmarshal([]byte(got), &result); err != nil {
t.Fatalf("decode command output: %v\noutput: %s", err, got)
}
messages, ok := result["messages"].([]any)
if !ok || len(messages) != 2 {
t.Fatalf("messages = %#v", result["messages"])
}
message, ok := messages[0].(map[string]any)
if !ok {
t.Fatalf("message = %#v", messages[0])
}
if message["messageId"] != "msg-top-1" || message["text"] != "顶层正文" {
t.Fatalf("stable fields = %#v", message)
}
if message["msgType"] != "text" || message["senderName"] != "张三" {
t.Fatalf("legacy message fields = %#v", message)
}
if extension, ok := message["extensionField"].(map[string]any); !ok || extension["source"] != "legacy" {
t.Fatalf("extensionField = %#v", message["extensionField"])
}
stableOnly, ok := messages[1].(map[string]any)
if !ok || stableOnly["openMessageId"] != "msg-top-2" || stableOnly["content"] != "稳定正文" {
t.Fatalf("legacy aliases from stable fields = %#v", messages[1])
}
if result["count"] != float64(99) || result["partial"] != true || result["failedCount"] != float64(1) {
t.Fatalf("legacy top-level envelope fields = %#v", result)
}
if _, exists := result["contractVersion"]; exists {
t.Fatalf("typed list added an out-of-scope contract envelope: %#v", result)
}
failures, ok := result["failures"].([]any)
if !ok || len(failures) != 1 || failures[0].(map[string]any)["stage"] != "legacy" {
t.Fatalf("legacy failures = %#v", result["failures"])
}
}
func TestCrossPlatformCoverageChatMessageListDryRunKeepsPreviewPath(t *testing.T) {
caller := &imReadResultCaller{dryRun: true}
got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
"message", "list", "--group", "cid-1", "--time", "2026-07-14 00:00:00")
if err != nil {
t.Fatalf("chat message list dry-run returned error: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run calls = %#v, want none", caller.calls)
}
var preview map[string]any
if err := json.Unmarshal([]byte(got), &preview); err != nil {
t.Fatalf("decode dry-run output: %v\noutput: %s", err, got)
}
if preview["dry_run"] != true || preview["executed"] != false || preview["tool"] != "list_conversation_message_v2" {
t.Fatalf("dry-run preview = %#v", preview)
}
}
func TestCrossPlatformCoverageChatMessageListRawPreservesLargeIntegers(t *testing.T) {
const payload = `{"messages":[{"openMessageId":"msg-1","content":{"text":"正文"},"sequence":9007199254740993}],"hasMore":false}`
caller := &imReadResultCaller{responses: map[string]string{"list_conversation_message_v2": payload}}
got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
"message", "list", "--group", "cid-1", "--time", "2026-07-14 00:00:00", "--format", "raw")
if err != nil {
t.Fatalf("chat message list returned error: %v", err)
}
if !bytes.Contains([]byte(got), []byte(`"sequence":9007199254740993`)) {
t.Fatalf("raw output changed large integer: %s", got)
}
}
func TestDingMessageListPreservesContent(t *testing.T) {
+110 -2
View File
@@ -124,6 +124,111 @@ func NewMessageListPayload(messages []map[string]any) map[string]any {
}
}
// ListMessageItems returns message rows from the common list response envelopes.
func ListMessageItems(data map[string]any) []map[string]any {
if data == nil {
return nil
}
scopes := []map[string]any{data}
for _, wrapper := range []string{"result", "data"} {
if inner, ok := data[wrapper].(map[string]any); ok {
scopes = append(scopes, inner)
}
}
for _, scope := range scopes {
for _, key := range []string{"messages", "list", "items", "records", "data", "result"} {
if rows, ok := scope[key].([]any); ok {
items := messageMaps(rows)
if len(items) > 0 {
return items
}
}
}
}
return nil
}
// SearchMessageItems flattens grouped search results into stable message rows.
func SearchMessageItems(data map[string]any) []map[string]any {
if data == nil {
return nil
}
for _, root := range []map[string]any{data, childMap(data, "result")} {
if root == nil {
continue
}
if groups, ok := root["conversationMessagesList"].([]any); ok {
return flattenSearchMessageGroups(groups)
}
}
for _, key := range []string{"list", "messages", "messageList", "items", "data", "records", "result"} {
if rows, ok := data[key].([]any); ok {
return messageMaps(rows)
}
if inner, ok := data[key].(map[string]any); ok {
for _, nestedKey := range []string{"list", "messages", "messageList", "items", "data", "records"} {
if rows, ok := inner[nestedKey].([]any); ok {
return messageMaps(rows)
}
}
}
}
return nil
}
func childMap(data map[string]any, key string) map[string]any {
value, _ := data[key].(map[string]any)
return value
}
func flattenSearchMessageGroups(groups []any) []map[string]any {
items := make([]map[string]any, 0)
for _, rawGroup := range groups {
group, ok := rawGroup.(map[string]any)
if !ok {
continue
}
messages, ok := group["messages"].([]any)
if !ok {
continue
}
conversationID := strings.TrimSpace(fmt.Sprint(group["openConversationId"]))
conversationTitle := strings.TrimSpace(fmt.Sprint(group["title"]))
singleChat, hasSingleChat := group["singleChat"]
for _, rawMessage := range messages {
message, ok := rawMessage.(map[string]any)
if !ok {
continue
}
item := make(map[string]any, len(message)+3)
for key, value := range message {
item[key] = value
}
if _, exists := item["openConversationId"]; !exists && conversationID != "" && conversationID != "<nil>" {
item["openConversationId"] = conversationID
}
if _, exists := item["conversationTitle"]; !exists && conversationTitle != "" && conversationTitle != "<nil>" {
item["conversationTitle"] = conversationTitle
}
if _, exists := item["singleChat"]; !exists && hasSingleChat {
item["singleChat"] = singleChat
}
items = append(items, item)
}
}
return items
}
func messageMaps(rows []any) []map[string]any {
items := make([]map[string]any, 0, len(rows))
for _, row := range rows {
if item, ok := row.(map[string]any); ok {
items = append(items, item)
}
}
return items
}
// StableMessageID returns the normalized message identity used for
// cross-page deduplication. An empty value means the lower response did not
// publish a stable identity; callers must keep that row rather than guessing.
@@ -184,7 +289,7 @@ func senderDisplayName(m map[string]any) string {
// Text reads a message's textual content (tolerating common text keys and one
// level of nesting) and runs it through CleanText.
func Text(m map[string]any) any {
for _, key := range []string{"text", "content", "msgContent", "message", "body", "plainText"} {
for _, key := range []string{"text", "content", "msgContent", "message", "msg", "body", "plainText"} {
v, ok := m[key]
if !ok || v == nil {
continue
@@ -195,7 +300,7 @@ func Text(m map[string]any) any {
return CleanText(t)
}
case map[string]any:
for _, inner := range []string{"text", "content", "value"} {
for _, inner := range []string{"text", "content", "richText", "title", "value"} {
if s, ok := t[inner].(string); ok && s != "" {
return CleanText(s)
}
@@ -920,6 +1025,9 @@ func paginationValuePresent(value any) bool {
switch typed := value.(type) {
case nil:
return false
case json.Number:
number, err := typed.Float64()
return err != nil || number != 0
case string:
return strings.TrimSpace(typed) != "" && strings.TrimSpace(typed) != "0"
case int:
+44
View File
@@ -595,3 +595,47 @@ func TestCrossPlatformCoverageForwarded(t *testing.T) {
t.Errorf("nested forwarded = %#v", fwd[1]["forwarded"])
}
}
func TestCrossPlatformCoverageListMessageItemsUnwrapsCommonEnvelope(t *testing.T) {
if ListMessageItems(nil) != nil {
t.Fatal("nil list envelope returned messages")
}
items := ListMessageItems(map[string]any{
"result": map[string]any{
"messages": []any{
map[string]any{"openMessageId": "msg-1"},
"invalid",
},
},
})
if len(items) != 1 || items[0]["openMessageId"] != "msg-1" {
t.Fatalf("items = %#v", items)
}
}
func TestCrossPlatformCoverageSearchMessageItemsFlattensConversationGroups(t *testing.T) {
if SearchMessageItems(nil) != nil {
t.Fatal("nil search envelope returned messages")
}
items := SearchMessageItems(map[string]any{
"result": map[string]any{
"conversationMessagesList": []any{
map[string]any{
"openConversationId": "cid-1",
"title": "项目群",
"singleChat": false,
"messages": []any{
map[string]any{"openMessageId": "msg-1"},
},
},
},
},
})
if len(items) != 1 || items[0]["openConversationId"] != "cid-1" ||
items[0]["conversationTitle"] != "项目群" || items[0]["singleChat"] != false {
t.Fatalf("items = %#v", items)
}
if SearchMessageItems(map[string]any{"result": "invalid"}) != nil {
t.Fatal("non-map result was accepted")
}
}
@@ -114,6 +114,9 @@ func TestCrossPlatformCoveragePaginationVariants(t *testing.T) {
want bool
}{
{nil, false},
{json.Number("0"), false},
{json.Number("0.0"), false},
{json.Number("9007199254740993"), true},
{" ", false},
{"0", false},
{"cursor", true},
@@ -99,6 +99,16 @@ func TestCrossPlatformCoverageSearchMsgProject(t *testing.T) {
t.Fatalf("searchMsgProject = %#v", row)
}
// Canonical ID precedence and rich-text extraction must match typed search.
row = searchMsgProject(map[string]any{
"openMessageId": "open-id",
"messageId": "legacy-conflict",
"content": map[string]any{"richText": "富文本消息"},
})
if row["messageId"] != "open-id" || row["text"] != "富文本消息" {
t.Fatalf("searchMsgProject canonical fields = %#v", row)
}
// encrypted → marker; id-only sender; forwarded "null" sender nulled.
row = searchMsgProject(map[string]any{
"senderId": "DAAA",
@@ -115,7 +125,7 @@ func TestCrossPlatformCoverageSearchMsgProject(t *testing.T) {
t.Errorf("searchMsgProject encrypted text = %v, want marker", row["text"])
}
fwd, ok := row["forwarded"].([]map[string]any)
if !ok || len(fwd) != 1 || fwd[0]["sender"] != nil {
if !ok || len(fwd) != 1 || fwd[0]["sender"] != nil || fwd[0]["time"] != "t" {
t.Errorf("searchMsgProject forwarded = %#v", row["forwarded"])
}
+3 -26
View File
@@ -91,7 +91,7 @@ var ChatMessages = shortcut.Shortcut{
AvoidWhen: []string{"以发送者、关键词、@对象或消息类型为主的直接条件检索优先使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget。已选择会话读取时可在同一次调用附带发送者姓名,不需要再搜索消息"},
Examples: []string{
"dws chat +chat-messages --group <openConversationId> --direction older",
"dws chat +chat-messages --open-dingtalk-id <openDingTalkId> --download-resources --output-dir ./downloads",
"dws chat +chat-messages --group <openConversationId> --direction older --jq '.messages[] | {messageId, text}'",
},
},
},
@@ -150,6 +150,7 @@ var ChatMessages = shortcut.Shortcut{
`dws chat +chat-messages --group <openconversation_id> --start "2025-03-01T00:00:00+08:00" --end "2025-03-02T00:00:00+08:00" --order asc --page-all`,
`dws chat +chat-messages --user <userId> --time "2025-03-01 00:00:00" --page-all --page-limit 50`,
`dws chat +chat-messages --group <openconversation_id> --direction older --page-all --output ./exports/messages.json`,
`dws chat +chat-messages --group <openconversation_id> --direction older --jq '.messages[] | {messageId, text}'`,
},
Validate: validateChatMessages,
Execute: executeChatMessages,
@@ -743,31 +744,7 @@ func chatMessagesNextCursorBoundary(value any) (string, string, error) {
// tolerating the common container keys and one level of nesting under a
// "result"/"data" wrapper.
func chatMessageItems(data map[string]any) []map[string]any {
if data == nil {
return nil
}
scopes := []map[string]any{data}
for _, wrap := range []string{"result", "data"} {
if inner, ok := data[wrap].(map[string]any); ok {
scopes = append(scopes, inner)
}
}
for _, scope := range scopes {
for _, key := range []string{"messages", "list", "items", "records", "data", "result"} {
if raw, ok := scope[key].([]any); ok {
out := make([]map[string]any, 0, len(raw))
for _, e := range raw {
if m, ok := e.(map[string]any); ok {
out = append(out, m)
}
}
if len(out) > 0 {
return out
}
}
}
}
return nil
return chatmsg.ListMessageItems(data)
}
// projectChatMessage reshapes one raw message into the clean
+1 -1
View File
@@ -233,7 +233,7 @@ func TestCrossPlatformCoverageSearchMsgFlattensRealGroupedResponse(t *testing.T)
if _, mutated := original["openConversationId"]; mutated {
t.Fatalf("source message mutated: %#v", original)
}
if searchMsgChildMap(map[string]any{"result": "invalid"}, "result") != nil {
if searchMsgItems(map[string]any{"result": "invalid"}) != nil {
t.Fatal("non-map child was accepted")
}
}
+10 -129
View File
@@ -67,7 +67,7 @@ var SearchMsg = shortcut.Shortcut{
AvoidWhen: []string{"只想查看或导出一个指定会话的消息记录、且没有发送者、关键词、@对象或消息类型等主要筛选条件时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"},
Examples: []string{
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all",
"dws chat +search-msg --group <openConversationId> --message-type file --download-resources --output-dir ./downloads",
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all --jq '.messages[] | {messageId, text}'",
},
},
},
@@ -138,6 +138,7 @@ var SearchMsg = shortcut.Shortcut{
Tips: []string{
`dws chat +search-msg --group <openConversationId> --query "changefree"`,
`dws chat +search-msg --senders <openDingTalkId> --at-me --days 3 --page-all`,
`dws chat +search-msg --group <openConversationId> --query "changefree" --jq '.messages[] | {messageId, text}'`,
},
Validate: validateSearchMsgWithResources,
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -505,106 +506,21 @@ func enrichSearchMessages(rt *shortcut.RuntimeContext, messages []map[string]any
// response, probing common container keys at the top level and nested under
// "result". Returns nil when no list is found.
func searchMsgItems(data map[string]any) []map[string]any {
if data == nil {
return nil
}
for _, root := range []map[string]any{data, searchMsgChildMap(data, "result")} {
if root == nil {
continue
}
if groups, ok := root["conversationMessagesList"].([]any); ok {
return searchMsgFlattenGroups(groups)
}
}
keys := []string{"list", "messages", "messageList", "items", "data", "records", "result"}
for _, key := range keys {
if arr, ok := data[key].([]any); ok {
return searchMsgToMaps(arr)
}
if inner, ok := data[key].(map[string]any); ok {
for _, k2 := range []string{"list", "messages", "messageList", "items", "data", "records"} {
if arr, ok := inner[k2].([]any); ok {
return searchMsgToMaps(arr)
}
}
}
}
return nil
return chatmsg.SearchMessageItems(data)
}
func searchMsgChildMap(data map[string]any, key string) map[string]any {
if value, ok := data[key].(map[string]any); ok {
return value
}
return nil
}
func searchMsgFlattenGroups(groups []any) []map[string]any {
out := make([]map[string]any, 0)
for _, rawGroup := range groups {
group, ok := rawGroup.(map[string]any)
if !ok {
continue
}
messages, ok := group["messages"].([]any)
if !ok {
continue
}
conversationID := strings.TrimSpace(fmt.Sprint(group["openConversationId"]))
conversationTitle := strings.TrimSpace(fmt.Sprint(group["title"]))
singleChat, hasSingleChat := group["singleChat"]
for _, rawMessage := range messages {
message, ok := rawMessage.(map[string]any)
if !ok {
continue
}
item := make(map[string]any, len(message)+3)
for key, value := range message {
item[key] = value
}
if _, exists := item["openConversationId"]; !exists &&
conversationID != "" && conversationID != "<nil>" {
item["openConversationId"] = conversationID
}
if _, exists := item["conversationTitle"]; !exists &&
conversationTitle != "" && conversationTitle != "<nil>" {
item["conversationTitle"] = conversationTitle
}
if _, exists := item["singleChat"]; !exists && hasSingleChat {
item["singleChat"] = singleChat
}
out = append(out, item)
}
}
return out
}
func searchMsgToMaps(arr []any) []map[string]any {
out := make([]map[string]any, 0, len(arr))
for _, it := range arr {
if m, ok := it.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// searchMsgProject reshapes one matched message into {sender, time, text,
// messageId}, running text through the shared chatmsg cleaning (card/auto-reply
// JSON → readable, ciphertext → marker) and recursively expanding any forwarded
// chat record under "forwarded".
// searchMsgProject adds search-only sender/time aliases and forwarded-message
// expansion to the shared message projection.
func searchMsgProject(m map[string]any) map[string]any {
return searchMsgProjectWithReactions(m, true)
}
func searchMsgProjectWithReactions(m map[string]any, includeReactions bool) map[string]any {
row := chatmsg.ProjectMessageV1(m, includeReactions)
// Preserve the established search aliases while also publishing the V1
// canonical fields used by other message readers.
// Keep search-only aliases while messageId/text stay owned by the shared
// projection used by typed message search.
row["sender"] = searchMsgSender(m)
row["time"] = searchMsgTime(m)
row["text"] = searchMsgCleanText(m)
row["messageId"] = searchMsgMessageID(m)
if forwarded := chatmsg.Forwarded(m, func(item map[string]any) map[string]any {
return searchMsgProjectWithReactions(item, includeReactions)
}); len(forwarded) > 0 {
@@ -613,14 +529,6 @@ func searchMsgProjectWithReactions(m map[string]any, includeReactions bool) map[
return row
}
// searchMsgCleanText runs searchMsgText's extraction through chatmsg.CleanText.
func searchMsgCleanText(m map[string]any) any {
if s, ok := searchMsgText(m).(string); ok {
return chatmsg.CleanText(s)
}
return searchMsgText(m)
}
// searchMsgSender reads a message's sender display name/id, tolerating the
// common sender keys the gateway may use (including a nested sender object). The
// literal string "null" (carried by forwarded sub-messages) and the empty string
@@ -639,8 +547,8 @@ func searchMsgSender(m map[string]any) any {
}
for _, key := range []string{"sender", "from", "senderUser"} {
if nested, ok := m[key].(map[string]any); ok {
for _, k2 := range []string{"name", "nick", "userName", "staffName", "displayName"} {
if v := norm(nested[k2]); v != "" {
for _, nestedKey := range []string{"name", "nick", "userName", "staffName", "displayName"} {
if v := norm(nested[nestedKey]); v != "" {
return v
}
}
@@ -668,39 +576,12 @@ func searchMsgTime(m map[string]any) any {
return nil
}
// searchMsgText reads a message's textual content, tolerating flat text keys and
// a nested content/text object.
func searchMsgText(m map[string]any) any {
for _, key := range []string{"text", "content", "msgContent", "message", "body"} {
if v := searchMsgString(m[key]); v != "" {
return v
}
}
for _, key := range []string{"content", "text", "msg"} {
if nested, ok := m[key].(map[string]any); ok {
for _, k2 := range []string{"text", "content", "richText", "title"} {
if v := searchMsgString(nested[k2]); v != "" {
return v
}
}
}
}
return nil
}
// searchMsgMessageID reads a message's identifier, tolerating the common id keys
// the gateway may use.
func searchMsgMessageID(m map[string]any) any {
for _, key := range []string{"messageId", "message_id", "msgId", "msg_id", "openMessageId", "id"} {
if v := searchMsgString(m[key]); v != "" {
return v
}
}
return nil
return chatmsg.MessageID(m)
}
// searchMsgString coerces a scalar JSON value to a trimmed string, returning ""
// for nil / non-scalar / empty values.
func searchMsgString(v any) string {
switch typed := v.(type) {
case string:
@@ -25,6 +25,7 @@ type searchMsgExecutionCaller struct {
omitMgetItem bool
firstResponse string
mgetResponse string
numericZeroEnd bool
}
func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
@@ -37,6 +38,9 @@ func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool str
if f.omitPagination {
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","content":"sparse-1"}]}}`), nil
}
if f.numericZeroEnd {
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","content":"sparse-1"}],"hasMore":false,"nextCursor":0}}`), nil
}
if args["cursor"] == "c2" {
if f.failSecondPage {
return nil, errors.New("second page unavailable")
@@ -192,6 +196,17 @@ func TestCrossPlatformCoverageSearchMsgMissingPaginationCannotClaimComplete(t *t
}
}
func TestCrossPlatformCoverageSearchMsgNumericZeroCursorIsComplete(t *testing.T) {
caller := &searchMsgExecutionCaller{numericZeroEnd: true}
payload := executeSearchMsg(t, caller, "--query", "周报", "--no-enrich")
if payload["complete"] != true || payload["hasMore"] != false || payload["paginationKnown"] != true {
t.Fatalf("numeric zero cursor pagination = %#v", payload)
}
if payload["nextCursor"] != "" {
t.Fatalf("numeric zero cursor exposed as next page: %#v", payload["nextCursor"])
}
}
func TestCrossPlatformCoverageSearchMsgMissingMgetItemPublishesFailureLedger(t *testing.T) {
caller := &searchMsgExecutionCaller{omitMgetItem: true}
payload := executeSearchMsg(t, caller, "--query", "周报", "--page-all")