Files
dingtalk-workspace-cli/internal/shortcut/smart/thread_replies.go

173 lines
8.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 smart
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
// ThreadReplies: fetch every reply under one topic ("话题") message and print a
// clean projected list (speaker / text / time) instead of a raw dump.
//
// Steps:
// 1. call list_topic_replies (chat server) with openconversationId=--group and
// topicId=--topic-id, optionally startTime=--time and pageSize=--limit —
// param keys copied verbatim from chat.go's list-topic-replies call site;
// 2. defensively unwrap the reply list (multiple candidate container keys) and
// project each reply to {sender, text, createTime} tolerating field aliases;
// 3. print via rt.Output as {replies, count} so it honours --format/--jq/--fields.
//
// The default path only reads and reshapes topic replies;
// --download-resources additionally writes resource files locally.
//
// dws chat +thread-replies --group <openconversationId> --thread-id <threadId>
var ThreadReplies = shortcut.Shortcut{
Service: "chat",
Command: "+thread-replies",
Product: "chat",
Description: "拉取某条话题消息的全部回复并投影出发言人/文本/时间",
Intent: "当你已经拿到某个群里一条「话题消息」的 threadId/topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么)," +
"而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --thread-id(兼容 --topic-id)拉取该话题的回复列表," +
"可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。" +
"默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "chat",
Name: "shortcut_thread_replies",
CanonicalPath: "chat.shortcut_thread_replies",
CLIPath: "chat +thread-replies",
PrimaryCLIPath: "chat +thread-replies",
},
Description: "拉取某条话题消息的全部回复并投影出发言人/文本/时间",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed thread reader adapter: it accepts threadId/topicId, reads lower topic replies, projects stable message fields, and optionally downloads reply resources safely.",
},
Selection: contract.SelectionSpec{
AgentSummary: "拉取某条话题消息的全部回复并投影出发言人/文本/时间",
UseWhen: []string{"当你已经拿到某个群里一条「话题消息」的 threadId/topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么),而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --thread-id(兼容 --topic-id)拉取该话题的回复列表,可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"},
AvoidWhen: []string{"要回复 Thread 或发送新回复时不要使用此读取入口;当前没有经过验证的 thread writer Shortcut"},
Examples: []string{
"dws chat +thread-replies --group <openConversationId> --thread-id <threadId>",
"dws chat +thread-replies --group <openConversationId> --topic-id <topicId> --download-resources --output-dir ./downloads",
},
},
},
Flags: append([]shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群会话 ID(openConversationId,必填)", Required: true},
{Name: "thread-id", Type: shortcut.FlagString, Desc: "话题/线程 ID(可直接使用消息列表返回的 threadId)"},
{Name: "topic-id", Type: shortcut.FlagString, Desc: "--thread-id 的兼容别名"},
{Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"(可选)"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的回复条数(可选)"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出回复 reaction(默认输出)"},
}, chatshortcut.MessageResourceDownloadFlags()...),
Constraints: append([]shortcut.Constraint{
{
Kind: shortcut.ConstraintExactlyOne,
Flags: []string{"thread-id", "topic-id"},
Description: "--thread-id 与兼容参数 --topic-id 必须且只能指定一个",
},
}, chatshortcut.MessageResourceDownloadConstraints()...),
Tips: []string{
`dws chat +thread-replies --group <openconversationId> --thread-id <threadId>`,
`dws chat +thread-replies --group <openconversationId> --thread-id <threadId> --time "2025-03-01 00:00:00" --limit 20`,
},
Validate: chatshortcut.ValidateMessageResourceDownload,
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — fetch the topic replies. Param keys (openconversationId /
// topicId / startTime / pageSize) are copied verbatim from chat.go's
// list-topic-replies call site.
params := map[string]any{
"openconversationId": rt.Str("group"),
"topicId": rt.StrFirst("thread-id", "topic-id"),
"forward": false,
}
if rt.Changed("time") && rt.Str("time") != "" {
params["startTime"] = rt.Str("time")
}
if rt.Changed("limit") && rt.Int("limit") > 0 {
params["pageSize"] = rt.Int("limit")
}
data, err := rt.CallMCPData("chat", "list_topic_replies", params)
if err != nil {
return err
}
// Step 2 — defensively unwrap and project. Response shape has no
// contract, so probe multiple candidate container/field keys.
items := threadReplyItems(data)
results := make([]map[string]any, 0, len(items))
for _, m := range items {
results = append(results, projectChatMessageWithReactions(m, !rt.Bool("no-reactions")))
}
payload := map[string]any{
"replies": results,
"count": len(results),
}
chatmsg.ApplyMessagePagination(payload, data, items, "older")
if rt.Bool("download-resources") {
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, rt.Str("group"))
}
return rt.Output(payload)
},
}
// threadReplyItems defensively unwraps the reply list from the response,
// tolerating the common container keys and one level of nesting under a
// "result"/"data" wrapper.
func threadReplyItems(data map[string]any) []map[string]any {
if data == nil {
return nil
}
// Try the list keys directly on the response, then inside a wrapper.
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{"replies", "list", "items", "messages", "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
}
func init() {
shortcut.Register(ThreadReplies)
}