1769 lines
77 KiB
Go
1769 lines
77 KiB
Go
// 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 chat
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
|
||
"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"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||
)
|
||
|
||
// MessagesSend sends a text/markdown message as the current user
|
||
// (send_personal_message, chat server). Media/file variants are not covered.
|
||
// MessagesReply quote-replies a message (send_personal_message, chat server).
|
||
// MessagesSendByBot sends a group message via a robot (send_robot_group_message, bot).
|
||
var MessagesSendByBot = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-send-by-bot",
|
||
Product: "bot",
|
||
Description: "机器人向群聊发送 Markdown 消息",
|
||
Intent: "当你要用机器人向某群推送 Markdown 消息(如日报、告警播报)时使用;会实际以机器人身份发群消息,需传 robotCode、群 openConversationId、标题与正文,可 @人或 @所有人。",
|
||
Risk: shortcut.RiskWrite,
|
||
Flags: []shortcut.Flag{
|
||
{Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code", Required: true},
|
||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||
{Name: "title", Type: shortcut.FlagString, Desc: "消息标题", Required: true},
|
||
{Name: "text", Type: shortcut.FlagString, Desc: "Markdown 正文", Required: true},
|
||
{Name: "at-user-ids", Type: shortcut.FlagStringSlice, Desc: "@ 的 userId 列表"},
|
||
{Name: "at-open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "@ 的 openDingTalkId 列表"},
|
||
{Name: "at-all", Type: shortcut.FlagBool, Desc: "@ 所有人"},
|
||
},
|
||
Tips: []string{`dws chat +messages-send-by-bot --robot-code <robotCode> --group <openConversationId> --title "日报" --text "## 今日完成"`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
params := map[string]any{
|
||
"robotCode": rt.Str("robot-code"),
|
||
"openConversationId": rt.Str("group"),
|
||
"title": rt.Str("title"),
|
||
"markdown": rt.Str("text"),
|
||
}
|
||
if v := rt.StrSlice("at-user-ids"); len(v) > 0 {
|
||
params["atUserIds"] = v
|
||
}
|
||
if v := rt.StrSlice("at-open-dingtalk-ids"); len(v) > 0 {
|
||
params["atOpendingtalkIds"] = v
|
||
}
|
||
if rt.Bool("at-all") {
|
||
params["isAtAll"] = "true"
|
||
}
|
||
return rt.CallMCP("send_robot_group_message", params)
|
||
},
|
||
}
|
||
|
||
// MessagesBatchSendByBot sends single-chat messages via a robot to users
|
||
// (batch_send_robot_msg_to_users, bot).
|
||
var MessagesBatchSendByBot = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-batch-send-by-bot",
|
||
Product: "bot",
|
||
Description: "机器人批量向用户发送单聊 Markdown 消息",
|
||
Intent: "当你要用机器人给多个人分别发单聊 Markdown 消息(如批量提醒交周报)时使用;会实际批量发送单聊消息,需传 robotCode、接收人列表(userId 或 openDingTalkId)及标题正文。",
|
||
Risk: shortcut.RiskWrite,
|
||
Flags: []shortcut.Flag{
|
||
{Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code", Required: true},
|
||
{Name: "title", Type: shortcut.FlagString, Desc: "消息标题", Required: true},
|
||
{Name: "text", Type: shortcut.FlagString, Desc: "Markdown 正文", Required: true},
|
||
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 userId 列表"},
|
||
{Name: "open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表"},
|
||
{Name: "at-all", Type: shortcut.FlagBool, Desc: "@ 所有人"},
|
||
},
|
||
Tips: []string{`dws chat +messages-batch-send-by-bot --robot-code <robotCode> --users userId1,userId2 --title "提醒" --text "请提交周报"`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
params := map[string]any{
|
||
"robotCode": rt.Str("robot-code"),
|
||
"title": rt.Str("title"),
|
||
"markdown": rt.Str("text"),
|
||
}
|
||
if v := rt.StrSlice("users"); len(v) > 0 {
|
||
params["userIds"] = v
|
||
}
|
||
if v := rt.StrSlice("open-dingtalk-ids"); len(v) > 0 {
|
||
params["openDingtalkIds"] = v
|
||
}
|
||
if rt.Bool("at-all") {
|
||
params["isAtAll"] = "true"
|
||
}
|
||
return rt.CallMCP("batch_send_robot_msg_to_users", params)
|
||
},
|
||
}
|
||
|
||
// MessagesSendByWebhook sends via a custom robot webhook (send_message_by_custom_robot, bot).
|
||
var MessagesSendByWebhook = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-send-by-webhook",
|
||
Product: "bot",
|
||
Description: "自定义机器人 Webhook 发送群消息",
|
||
Intent: "当你只有自定义机器人的 Webhook token、想往其所在群推送消息时使用;会实际通过 Webhook 发群消息,需传 token、标题、正文,可 @手机号/userId 或 @所有人。",
|
||
Risk: shortcut.RiskWrite,
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "user_required", Idempotency: "unknown",
|
||
},
|
||
Contract: corecmd.ContractDecl{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "chat",
|
||
Name: "shortcut_messages_send_by_webhook",
|
||
CanonicalPath: "chat.shortcut_messages_send_by_webhook",
|
||
CLIPath: "chat +messages-send-by-webhook",
|
||
PrimaryCLIPath: "chat +messages-send-by-webhook",
|
||
},
|
||
Description: "自定义机器人 Webhook 发送群消息",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "自定义机器人 Webhook 发送群消息",
|
||
UseWhen: []string{"当你只有自定义机器人的 Webhook token、想往其所在群推送消息时使用;会实际通过 Webhook 发群消息,需传 token、标题、正文,可 @手机号/userId 或 @所有人。"},
|
||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||
Examples: []string{"dws chat +messages-send-by-webhook --token <token> --title \"告警\" --text \"CPU 超 90%\" --at-all"},
|
||
},
|
||
},
|
||
Flags: []shortcut.Flag{
|
||
{Name: "token", Type: shortcut.FlagString, Desc: "Webhook token", Required: true},
|
||
{Name: "title", Type: shortcut.FlagString, Desc: "消息标题", Required: true},
|
||
{Name: "text", Type: shortcut.FlagString, Desc: "消息正文", Required: true},
|
||
{Name: "at-all", Type: shortcut.FlagBool, Desc: "@ 所有人"},
|
||
{Name: "at-mobiles", Type: shortcut.FlagStringSlice, Desc: "@ 的手机号列表"},
|
||
{Name: "at-users", Type: shortcut.FlagStringSlice, Desc: "@ 的 userId 列表"},
|
||
},
|
||
Tips: []string{`dws chat +messages-send-by-webhook --token <token> --title "告警" --text "CPU 超 90%" --at-all`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
params := map[string]any{
|
||
"robotToken": rt.Str("token"),
|
||
"title": rt.Str("title"),
|
||
"text": rt.Str("text"),
|
||
}
|
||
if rt.Bool("at-all") {
|
||
params["isAtAll"] = true
|
||
}
|
||
if v := rt.StrSlice("at-mobiles"); len(v) > 0 {
|
||
params["atMobiles"] = v
|
||
}
|
||
if v := rt.StrSlice("at-users"); len(v) > 0 {
|
||
params["atUserIds"] = v
|
||
}
|
||
return rt.CallMCP("send_message_by_custom_robot", params)
|
||
},
|
||
}
|
||
|
||
// MessagesRecall recalls a message sent by the current user (recall_message, im).
|
||
var MessagesRecall = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-recall",
|
||
Product: "im",
|
||
Description: "撤回当前用户发送的消息",
|
||
Intent: "当你想撤回当前用户刚发出的某条消息时使用;会实际撤回消息,需传会话 openConversationId 和消息 openMessageId。",
|
||
Risk: shortcut.RiskWrite,
|
||
Flags: []shortcut.Flag{
|
||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||
{Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true},
|
||
},
|
||
Tips: []string{`dws chat +messages-recall --conversation-id <openConversationId> --msg-id <openMessageId>`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
return rt.CallMCP("recall_message", map[string]any{
|
||
"openConversationId": rt.Str("conversation-id"),
|
||
"openMessageId": rt.Str("msg-id"),
|
||
})
|
||
},
|
||
}
|
||
|
||
// MessagesRecallByBot recalls a robot group message (recall_robot_group_message, bot).
|
||
var MessagesRecallByBot = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-recall-by-bot",
|
||
Product: "bot",
|
||
Description: "机器人撤回群消息",
|
||
Intent: "当你要撤回机器人此前发到群里的消息时使用;会实际撤回机器人群消息,需传 robotCode、群 openConversationId 和发送时返回的 processQueryKey 列表。",
|
||
Risk: shortcut.RiskWrite,
|
||
Flags: []shortcut.Flag{
|
||
{Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code", Required: true},
|
||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||
{Name: "keys", Type: shortcut.FlagStringSlice, Desc: "发送时返回的 processQueryKey 列表", Required: true},
|
||
},
|
||
Tips: []string{`dws chat +messages-recall-by-bot --robot-code <robotCode> --group <openConversationId> --keys key1,key2`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
return rt.CallMCP("recall_robot_group_message", map[string]any{
|
||
"robotCode": rt.Str("robot-code"),
|
||
"openConversationId": rt.Str("group"),
|
||
"processQueryKeys": rt.StrSlice("keys"),
|
||
})
|
||
},
|
||
}
|
||
|
||
// MessagesBatchRecallByBot recalls robot single-chat messages (batch_recall_robot_users_msg, bot).
|
||
var MessagesBatchRecallByBot = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-batch-recall-by-bot",
|
||
Product: "bot",
|
||
Description: "机器人撤回单聊消息",
|
||
Intent: "当你要批量撤回机器人此前发出的单聊消息时使用;会实际撤回机器人单聊消息,需传 robotCode 和发送时返回的 processQueryKey 列表。",
|
||
Risk: shortcut.RiskWrite,
|
||
Flags: []shortcut.Flag{
|
||
{Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code", Required: true},
|
||
{Name: "keys", Type: shortcut.FlagStringSlice, Desc: "发送时返回的 processQueryKey 列表", Required: true},
|
||
},
|
||
Tips: []string{`dws chat +messages-batch-recall-by-bot --robot-code <robotCode> --keys key1,key2`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
return rt.CallMCP("batch_recall_robot_users_msg", map[string]any{
|
||
"robotCode": rt.Str("robot-code"),
|
||
"processQueryKeys": rt.StrSlice("keys"),
|
||
})
|
||
},
|
||
}
|
||
|
||
// MessagesList pulls messages of a group (list_conversation_message_v2, chat server).
|
||
var MessagesList = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-list",
|
||
Description: "拉取群聊会话消息",
|
||
Intent: "当你想按时间拉取某个群的历史聊天记录(做回顾或分析)时使用;只读,需传群 openConversationId 和起始时间,--forward 控制往新还是往旧方向翻。",
|
||
Risk: shortcut.RiskRead,
|
||
Flags: []shortcut.Flag{
|
||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId"},
|
||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
|
||
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
|
||
{Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"", Required: true},
|
||
{Name: "forward", Type: shortcut.FlagBool, Default: "true", Desc: "true=从给定时间往现在拉,false=往以前拉"},
|
||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量"},
|
||
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true},
|
||
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
|
||
},
|
||
Constraints: []shortcut.Constraint{
|
||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id"}},
|
||
},
|
||
Tips: []string{`dws chat +messages-list --group <openConversationId> --time "2025-03-01 00:00:00"`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
group := rt.StrFirst("group", "conversation-id", "id")
|
||
params := map[string]any{
|
||
"openconversation_id": group,
|
||
"time": rt.Str("time"),
|
||
"forward": rt.Bool("forward"),
|
||
}
|
||
if limit := rt.IntFirst("limit", "size"); limit > 0 {
|
||
params["limit"] = limit
|
||
}
|
||
data, err := rt.CallMCPData("chat", "list_conversation_message_v2", params)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
messages := listMessagesProjectWithReactions(data, !rt.Bool("no-reactions"))
|
||
payload := map[string]any{"count": len(messages), "messages": messages}
|
||
direction := "older"
|
||
if rt.Bool("forward") {
|
||
direction = "newer"
|
||
}
|
||
chatmsg.ApplyMessagePagination(payload, data, listMessagesResolveMaps(data), direction)
|
||
return rt.Output(payload)
|
||
},
|
||
}
|
||
|
||
// listMessagesProject reshapes a raw conversation-message list response into a
|
||
// clean {messageId, senderId, msgType, createTime, text} list — output-projection
|
||
// clean output projection. Both the list container and the per-item field names are
|
||
// probed defensively across candidate keys, so an empty or unexpected shape
|
||
// yields an empty list rather than a crash or fabricated data.
|
||
//
|
||
// Text goes through the shared chatmsg projection so card/auto-reply JSON is
|
||
// rendered readable and encrypted ciphertext is marked, and forwarded chat
|
||
// records ("聊天记录") expand their nested messages under "forwarded" instead of
|
||
// collapsing to a "[卡片]" summary.
|
||
func listMessagesProject(data map[string]any) []map[string]any {
|
||
return listMessagesProjectWithReactions(data, true)
|
||
}
|
||
|
||
func listMessagesProjectWithReactions(data map[string]any, includeReactions bool) []map[string]any {
|
||
raw := listMessagesResolveList(data)
|
||
out := make([]map[string]any, 0, len(raw))
|
||
for _, item := range raw {
|
||
m, ok := item.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if row := listMessageProjectOneWithReactions(m, includeReactions); len(row) > 0 {
|
||
out = append(out, row)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// listMessageProjectOne projects a single message into the native
|
||
// {messageId, senderId, msgType, createTime, text(, forwarded)} shape, reused
|
||
// recursively for forwarded chat records.
|
||
func listMessageProjectOne(m map[string]any) map[string]any {
|
||
return listMessageProjectOneWithReactions(m, true)
|
||
}
|
||
|
||
func listMessageProjectOneWithReactions(m map[string]any, includeReactions bool) map[string]any {
|
||
row := map[string]any{}
|
||
if v, ok := listMessagesFirst(m, "openMessageId", "openMsgId", "messageId", "msgId"); ok {
|
||
row["messageId"] = v
|
||
}
|
||
if v, ok := listMessagesFirst(m, "senderOpenDingTalkId", "senderUserId", "senderId", "senderStaffId"); ok {
|
||
row["senderId"] = v
|
||
}
|
||
if v, ok := listMessagesFirst(m, "msgType", "messageType", "type"); ok {
|
||
row["msgType"] = v
|
||
}
|
||
if v, ok := listMessagesFirst(m, "createTime", "sendTime", "gmtCreate", "messageTime"); ok {
|
||
row["createTime"] = v
|
||
}
|
||
if text := chatmsg.Text(m); text != nil {
|
||
row["text"] = text
|
||
}
|
||
if conversationID := chatmsg.ConversationID(m); conversationID != nil {
|
||
row["conversationId"] = conversationID
|
||
}
|
||
if threadID := chatmsg.ThreadID(m); threadID != nil {
|
||
row["threadId"] = threadID
|
||
}
|
||
if updateTime := chatmsg.UpdateTime(m); updateTime != nil {
|
||
row["updateTime"] = updateTime
|
||
}
|
||
if includeReactions {
|
||
if reactions := chatmsg.Reactions(m); len(reactions) > 0 {
|
||
row["reactions"] = reactions
|
||
}
|
||
}
|
||
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
|
||
row["quotedMessage"] = quoted
|
||
}
|
||
if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
|
||
row["resourceRefs"] = resources
|
||
}
|
||
projectForwarded := func(item map[string]any) map[string]any {
|
||
return listMessageProjectOneWithReactions(item, includeReactions)
|
||
}
|
||
if forwarded := chatmsg.Forwarded(m, projectForwarded); len(forwarded) > 0 {
|
||
row["forwarded"] = forwarded
|
||
}
|
||
return row
|
||
}
|
||
|
||
// listMessagesResolveList locates the list payload, tolerating a bare top-level
|
||
// array container or nesting one level deeper under a common envelope key.
|
||
func listMessagesResolveList(data map[string]any) []any {
|
||
for _, key := range []string{"result", "data", "list", "items", "messages", "messageList", "records"} {
|
||
v, ok := data[key]
|
||
if !ok {
|
||
continue
|
||
}
|
||
if arr, ok := v.([]any); ok {
|
||
return arr
|
||
}
|
||
if inner, ok := v.(map[string]any); ok {
|
||
for _, ik := range []string{"list", "items", "messages", "messageList", "records", "result", "data"} {
|
||
if arr, ok := inner[ik].([]any); ok {
|
||
return arr
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return []any{}
|
||
}
|
||
|
||
func listMessagesResolveMaps(data map[string]any) []map[string]any {
|
||
raw := listMessagesResolveList(data)
|
||
out := make([]map[string]any, 0, len(raw))
|
||
for _, item := range raw {
|
||
if message, ok := item.(map[string]any); ok {
|
||
out = append(out, message)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// listMessagesFirst returns the first present candidate key's value.
|
||
func listMessagesFirst(m map[string]any, keys ...string) (any, bool) {
|
||
for _, k := range keys {
|
||
if v, ok := m[k]; ok {
|
||
return v, true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
// MessagesListDirect pulls messages of a single chat (list_individual_chat_message, chat server).
|
||
var MessagesListDirect = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-list-direct",
|
||
Description: "拉取单聊会话消息",
|
||
Intent: "当你想按时间拉取与某人单聊的历史消息时使用;只读,需传对方 userId 或 openDingTalkId 及起始时间,--forward 控制翻页方向。",
|
||
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_messages_list_direct",
|
||
CanonicalPath: "chat.shortcut_messages_list_direct",
|
||
CLIPath: "chat +messages-list-direct",
|
||
PrimaryCLIPath: "chat +messages-list-direct",
|
||
},
|
||
Description: "拉取单聊会话消息",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "拉取单聊会话消息",
|
||
UseWhen: []string{"当你想按时间拉取与某人单聊的历史消息时使用;只读,需传对方 userId 或 openDingTalkId 及起始时间,--forward 控制翻页方向。"},
|
||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||
Examples: []string{"dws chat +messages-list-direct --user <userId> --time \"2025-03-01 00:00:00\""},
|
||
},
|
||
},
|
||
Flags: []shortcut.Flag{
|
||
{Name: "user", Type: shortcut.FlagString, Desc: "对方 userId(与 --open-dingtalk-id 二选一)"},
|
||
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "对方 openDingTalkId(与 --user 二选一)"},
|
||
{Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"", Required: true},
|
||
{Name: "forward", Type: shortcut.FlagBool, Default: "true", Desc: "true=往现在拉,false=往以前拉"},
|
||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量"},
|
||
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true},
|
||
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
|
||
},
|
||
Tips: []string{`dws chat +messages-list-direct --user <userId> --time "2025-03-01 00:00:00"`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
params := map[string]any{
|
||
"time": rt.Str("time"),
|
||
"forward": rt.Bool("forward"),
|
||
}
|
||
switch {
|
||
case rt.Str("open-dingtalk-id") != "":
|
||
params["openDingTalkId"] = rt.Str("open-dingtalk-id")
|
||
case rt.Str("user") != "":
|
||
params["userId"] = rt.Str("user")
|
||
default:
|
||
return fmt.Errorf("--user 或 --open-dingtalk-id 必填其一")
|
||
}
|
||
if limit := rt.IntFirst("limit", "size"); limit > 0 {
|
||
params["limit"] = limit
|
||
}
|
||
data, err := rt.CallMCPData("chat", "list_individual_chat_message", params)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
messages := listMessagesProjectWithReactions(data, !rt.Bool("no-reactions"))
|
||
payload := map[string]any{"count": len(messages), "messages": messages}
|
||
direction := "older"
|
||
if rt.Bool("forward") {
|
||
direction = "newer"
|
||
}
|
||
chatmsg.ApplyMessagePagination(payload, data, listMessagesResolveMaps(data), direction)
|
||
return rt.Output(payload)
|
||
},
|
||
}
|
||
|
||
// MessagesListTopicReplies pulls topic replies (list_topic_replies, chat server).
|
||
// MessagesListAll pulls all messages in a time range (search_messages_by_time_range, chat server).
|
||
// MessagesListFocused pulls messages from specially-followed people (list_special_focus_messages, chat server).
|
||
// MessagesListUnreadConversations lists conversations with unread messages
|
||
// (unread_message_conversation_list, chat server).
|
||
var MessagesListUnreadConversations = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-list-unread-conversations",
|
||
Description: "获取有未读消息的会话列表",
|
||
Intent: "当你想快速定位哪些会话还有未读消息时使用;只读返回有未读的会话列表,可用 --exclude-muted 排除已免打扰会话。",
|
||
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_messages_list_unread_conversations",
|
||
CanonicalPath: "chat.shortcut_messages_list_unread_conversations",
|
||
CLIPath: "chat +messages-list-unread-conversations",
|
||
PrimaryCLIPath: "chat +messages-list-unread-conversations",
|
||
},
|
||
Description: "获取有未读消息的会话列表",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "获取有未读消息的会话列表",
|
||
UseWhen: []string{"当你想快速定位哪些会话还有未读消息时使用;只读返回有未读的会话列表,可用 --exclude-muted 排除已免打扰会话。"},
|
||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||
Examples: []string{"dws chat +messages-list-unread-conversations --count 20"},
|
||
},
|
||
},
|
||
Flags: []shortcut.Flag{
|
||
{Name: "count", Type: shortcut.FlagInt, Desc: "返回的会话条数"},
|
||
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"},
|
||
},
|
||
Tips: []string{`dws chat +messages-list-unread-conversations --count 20`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
params := map[string]any{}
|
||
if rt.Int("count") > 0 {
|
||
params["count"] = rt.Int("count")
|
||
}
|
||
if rt.Bool("exclude-muted") {
|
||
params["excludeMuted"] = true
|
||
}
|
||
data, err := rt.CallMCPData("chat", "unread_message_conversation_list", params)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
conversations := listUnreadConversationsProject(data)
|
||
return rt.Output(map[string]any{"count": len(conversations), "conversations": conversations})
|
||
},
|
||
}
|
||
|
||
// listUnreadConversationsProject reshapes the raw unread_message_conversation_list
|
||
// response into a clean {conversationId, title, unreadCount, lastMessageTime} list
|
||
// — clean output projection. Both the list container and per-item
|
||
// field names are probed defensively across candidate keys, so an empty or
|
||
// unexpected shape yields an empty list rather than a crash or fabricated data.
|
||
func listUnreadConversationsProject(data map[string]any) []map[string]any {
|
||
raw := listUnreadConversationsResolveList(data)
|
||
out := make([]map[string]any, 0, len(raw))
|
||
for _, item := range raw {
|
||
m, ok := item.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
row := map[string]any{}
|
||
if v, ok := listUnreadConversationsFirst(m, "openConversationId", "conversationId", "openConvId", "cid"); ok {
|
||
row["conversationId"] = v
|
||
}
|
||
if v, ok := listUnreadConversationsFirst(m, "title", "conversationTitle", "name"); ok {
|
||
row["title"] = v
|
||
}
|
||
if v, ok := listUnreadConversationsFirst(m, "unreadCount", "unread", "unReadCount", "count"); ok {
|
||
row["unreadCount"] = v
|
||
}
|
||
if v, ok := listUnreadConversationsFirst(m, "lastMessageTime", "lastMsgTime", "latestMessageTime", "updateTime"); ok {
|
||
row["lastMessageTime"] = v
|
||
}
|
||
if len(row) > 0 {
|
||
out = append(out, row)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// listUnreadConversationsResolveList locates the list payload, tolerating a bare
|
||
// top-level array container or nesting one level deeper under a common envelope.
|
||
func listUnreadConversationsResolveList(data map[string]any) []any {
|
||
for _, key := range []string{"result", "data", "list", "items", "conversations", "conversationList"} {
|
||
v, ok := data[key]
|
||
if !ok {
|
||
continue
|
||
}
|
||
if arr, ok := v.([]any); ok {
|
||
return arr
|
||
}
|
||
if inner, ok := v.(map[string]any); ok {
|
||
for _, ik := range []string{"list", "items", "conversations", "conversationList", "result", "data"} {
|
||
if arr, ok := inner[ik].([]any); ok {
|
||
return arr
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return []any{}
|
||
}
|
||
|
||
// listUnreadConversationsFirst returns the first present candidate key's value.
|
||
func listUnreadConversationsFirst(m map[string]any, keys ...string) (any, bool) {
|
||
for _, k := range keys {
|
||
if v, ok := m[k]; ok {
|
||
return v, true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
// MessagesMget batch-queries messages by id (list_messages_by_ids, im).
|
||
var MessagesMget = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-mget",
|
||
Product: "im",
|
||
Description: "根据消息 ID 批量查询消息(最多 50 条)",
|
||
Intent: "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId/fileId 安全下载到工作目录内,并逐资源返回成功/失败 ledger;本地下载路径受限于工作目录、默认不覆盖同名文件,按既有安全下载约定无需交互确认。",
|
||
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_messages_mget",
|
||
CanonicalPath: "chat.shortcut_messages_mget",
|
||
CLIPath: "chat +messages-mget",
|
||
PrimaryCLIPath: "chat +messages-mget",
|
||
},
|
||
Description: "根据消息 ID 批量查询消息(最多 50 条)",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "根据消息 ID 批量查询消息(最多 50 条)",
|
||
UseWhen: []string{"当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId/fileId 安全下载到工作目录内,并逐资源返回成功/失败 ledger;本地下载路径受限于工作目录、默认不覆盖同名文件,按既有安全下载约定无需交互确认。"},
|
||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||
Examples: []string{"dws chat +messages-mget --msg-ids msgId1,msgId2"},
|
||
},
|
||
},
|
||
Flags: append([]shortcut.Flag{
|
||
{Name: "msg-ids", Type: shortcut.FlagStringSlice, Desc: "消息 openMsgId 列表;--msg-ids 去重后必须包含 1-50 条消息 ID", Required: true},
|
||
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
|
||
}, MessageResourceDownloadFlags()...),
|
||
Constraints: append([]shortcut.Constraint{
|
||
{
|
||
Kind: shortcut.ConstraintCustom,
|
||
Flags: []string{"msg-ids"},
|
||
Description: "--msg-ids 去重后必须包含 1-50 条消息 ID",
|
||
},
|
||
}, MessageResourceDownloadConstraints()...),
|
||
Tips: []string{`dws chat +messages-mget --msg-ids msgId1,msgId2`},
|
||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||
ids := uniqueShortcutStrings(rt.StrSlice("msg-ids"))
|
||
if len(ids) < 1 || len(ids) > 50 {
|
||
return fmt.Errorf("--msg-ids 去重后必须包含 1-50 条消息 ID,当前 %d 条", len(ids))
|
||
}
|
||
return ValidateMessageResourceDownload(rt)
|
||
},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
ids := uniqueShortcutStrings(rt.StrSlice("msg-ids"))
|
||
data, err := rt.CallMCPData("im", "list_messages_by_ids", map[string]any{"openMsgIds": ids})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rawMessages := listMessagesResolveMaps(data)
|
||
messages := listMessagesProjectWithReactions(data, !rt.Bool("no-reactions"))
|
||
found := map[string]bool{}
|
||
for _, message := range rawMessages {
|
||
if id := strings.TrimSpace(fmt.Sprint(chatmsg.MessageID(message))); id != "" && id != "<nil>" {
|
||
found[id] = true
|
||
}
|
||
}
|
||
notFound := make([]string, 0)
|
||
for _, id := range ids {
|
||
if !found[id] {
|
||
notFound = append(notFound, id)
|
||
}
|
||
}
|
||
payload := map[string]any{
|
||
"requestedCount": len(ids),
|
||
"foundCount": len(ids) - len(notFound),
|
||
"notFoundCount": len(notFound),
|
||
"notFoundMessageIds": notFound,
|
||
"messages": messages,
|
||
}
|
||
if rt.Bool("download-resources") {
|
||
payload["resourceDownloads"] = DownloadMessageResources(rt, rawMessages, "")
|
||
}
|
||
return rt.Output(payload)
|
||
},
|
||
}
|
||
|
||
// MessageResourceDownloadFlags returns the common opt-in resource workflow used
|
||
// by message list, search, mget, @me and thread-reading Shortcuts.
|
||
func MessageResourceDownloadFlags() []shortcut.Flag {
|
||
return []shortcut.Flag{
|
||
{Name: "download-resources", Type: shortcut.FlagBool, Desc: "自动下载消息中的全部可识别 mediaId/fileId 资源"},
|
||
{Name: "output-dir", Type: shortcut.FlagString, Default: "./downloads", Desc: "资源输出目录;必须是工作目录内的相对路径,禁止绝对路径和 .. 逃逸"},
|
||
{Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖同名资源文件(默认拒绝)"},
|
||
}
|
||
}
|
||
|
||
// MessageResourceDownloadConstraints publishes the shared safe-output rule.
|
||
func MessageResourceDownloadConstraints() []shortcut.Constraint {
|
||
return []shortcut.Constraint{{
|
||
Kind: shortcut.ConstraintCustom,
|
||
Flags: []string{"output-dir"},
|
||
Description: "--output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
|
||
}}
|
||
}
|
||
|
||
// ValidateMessageResourceDownload validates the output path only when the
|
||
// caller opts into local writes.
|
||
func ValidateMessageResourceDownload(rt *shortcut.RuntimeContext) error {
|
||
if !rt.Bool("download-resources") {
|
||
return nil
|
||
}
|
||
return validateResourceDownloadOutputFlag(rt.Str("output-dir"), "--output-dir")
|
||
}
|
||
|
||
// DownloadMessageResources downloads every unique message resource reference
|
||
// and returns a per-resource success/failure ledger. A fallback conversation
|
||
// ID lets group/thread list commands supply context when a mediaId item's lower
|
||
// response omits it; fileId resources route through the existing drive leaf.
|
||
func DownloadMessageResources(
|
||
rt *shortcut.RuntimeContext,
|
||
messages []map[string]any,
|
||
fallbackConversationID string,
|
||
) map[string]any {
|
||
resources := make([]map[string]any, 0)
|
||
for _, message := range messages {
|
||
resources = append(resources, chatmsg.ResourcesDeep(message)...)
|
||
}
|
||
discoveredCount := len(resources)
|
||
uniqueResources := make([]map[string]any, 0, len(resources))
|
||
seen := map[string]bool{}
|
||
for _, resource := range resources {
|
||
resourceType := strings.TrimSpace(fmt.Sprint(resource["type"]))
|
||
if canonicalType, ok := canonicalMessageResourceType(resourceType); ok {
|
||
resourceType = canonicalType
|
||
}
|
||
resourceID := strings.TrimSpace(fmt.Sprint(resource["resourceId"]))
|
||
download, _ := resource["download"].(map[string]any)
|
||
arguments, _ := download["arguments"].(map[string]any)
|
||
messageID := strings.TrimSpace(fmt.Sprint(arguments["message-id"]))
|
||
if messageID == "<nil>" {
|
||
messageID = ""
|
||
}
|
||
key := strings.ToLower(resourceType) + "\x00" + resourceID
|
||
if strings.EqualFold(resourceType, "mediaId") {
|
||
conversationID := strings.TrimSpace(fmt.Sprint(arguments["open-conversation-id"]))
|
||
key += "\x00" + messageID + "\x00" + conversationID
|
||
}
|
||
if resourceID != "" && resourceID != "<nil>" {
|
||
if seen[key] {
|
||
continue
|
||
}
|
||
seen[key] = true
|
||
}
|
||
uniqueResources = append(uniqueResources, resource)
|
||
}
|
||
resources = uniqueResources
|
||
if rt.DryRun() {
|
||
return map[string]any{
|
||
"dryRun": true,
|
||
"discoveredCount": discoveredCount,
|
||
"requestedCount": len(resources),
|
||
"deduplicatedCount": discoveredCount - len(resources),
|
||
"resources": resources,
|
||
}
|
||
}
|
||
if len(resources) == 0 {
|
||
return map[string]any{
|
||
"ok": true,
|
||
"partial": false,
|
||
"discoveredCount": discoveredCount,
|
||
"requestedCount": 0,
|
||
"deduplicatedCount": discoveredCount,
|
||
"downloadedCount": 0,
|
||
"failedCount": 0,
|
||
"downloads": []map[string]any{},
|
||
"failures": []map[string]any{},
|
||
}
|
||
}
|
||
|
||
cwd, err := resourceGetwd()
|
||
if err != nil {
|
||
return map[string]any{
|
||
"ok": false,
|
||
"partial": false,
|
||
"discoveredCount": discoveredCount,
|
||
"requestedCount": len(resources),
|
||
"deduplicatedCount": discoveredCount - len(resources),
|
||
"downloadedCount": 0,
|
||
"failedCount": len(resources),
|
||
"downloads": []map[string]any{},
|
||
"failures": []map[string]any{{
|
||
"stage": "output-directory",
|
||
"affectedCount": len(resources),
|
||
"error": fmt.Sprintf("读取工作目录失败: %v", err),
|
||
}},
|
||
}
|
||
}
|
||
outputDir := strings.TrimRight(rt.Str("output-dir"), `/\`)
|
||
downloads := make([]map[string]any, 0, len(resources))
|
||
failures := make([]map[string]any, 0)
|
||
downloadedNames := map[string]bool{}
|
||
for _, resource := range resources {
|
||
resourceType := strings.TrimSpace(fmt.Sprint(resource["type"]))
|
||
if canonicalType, ok := canonicalMessageResourceType(resourceType); ok {
|
||
resourceType = canonicalType
|
||
}
|
||
resourceID := strings.TrimSpace(fmt.Sprint(resource["resourceId"]))
|
||
download, _ := resource["download"].(map[string]any)
|
||
arguments, _ := download["arguments"].(map[string]any)
|
||
messageID := strings.TrimSpace(fmt.Sprint(arguments["message-id"]))
|
||
conversationID := strings.TrimSpace(fmt.Sprint(arguments["open-conversation-id"]))
|
||
if messageID == "<nil>" {
|
||
messageID = ""
|
||
}
|
||
if conversationID == "" || conversationID == "<nil>" {
|
||
conversationID = strings.TrimSpace(fallbackConversationID)
|
||
}
|
||
missingMediaContext := resourceType == "mediaId" &&
|
||
(messageID == "" || messageID == "<nil>" ||
|
||
conversationID == "" || conversationID == "<nil>")
|
||
if resourceID == "" || resourceID == "<nil>" || missingMediaContext {
|
||
failures = append(failures, map[string]any{
|
||
"resourceType": resourceType,
|
||
"resourceId": resourceID,
|
||
"messageId": messageID,
|
||
"error": "资源引用缺少 resource-id,或 mediaId 缺少 message-id/open-conversation-id",
|
||
})
|
||
continue
|
||
}
|
||
|
||
data, callErr := resolveMessageResourceDownloadData(
|
||
rt, resourceType, resourceID, messageID, conversationID)
|
||
if callErr != nil {
|
||
failures = append(failures, map[string]any{
|
||
"resourceType": resourceType,
|
||
"resourceId": resourceID,
|
||
"messageId": messageID,
|
||
"error": callErr.Error(),
|
||
})
|
||
continue
|
||
}
|
||
resourceURL, headers, infoErr := resourceDownloadInfo(data)
|
||
if infoErr != nil {
|
||
failures = append(failures, map[string]any{
|
||
"resourceType": resourceType,
|
||
"resourceId": resourceID,
|
||
"messageId": messageID,
|
||
"error": infoErr.Error(),
|
||
})
|
||
continue
|
||
}
|
||
preferredName := resourceDownloadPreferredName(data)
|
||
filename := resourceDownloadFilename(resourceURL, preferredName)
|
||
filename = disambiguateResourceDownloadFilename(filename, downloadedNames)
|
||
output := filepath.Join(outputDir, filename)
|
||
destPath, relativePath, pathErr := resolveResourceDownloadPath(
|
||
cwd,
|
||
output,
|
||
resourceURL,
|
||
rt.Bool("overwrite"),
|
||
preferredName,
|
||
)
|
||
if pathErr != nil {
|
||
failures = append(failures, map[string]any{
|
||
"resourceType": resourceType,
|
||
"resourceId": resourceID,
|
||
"messageId": messageID,
|
||
"error": pathErr.Error(),
|
||
})
|
||
continue
|
||
}
|
||
size, downloadErr := resourceDownload(
|
||
rt.Command().Context(), nil, resourceURL, headers, destPath, rt.Bool("overwrite"))
|
||
if downloadErr != nil {
|
||
failures = append(failures, map[string]any{
|
||
"resourceType": resourceType,
|
||
"resourceId": resourceID,
|
||
"messageId": messageID,
|
||
"error": downloadErr.Error(),
|
||
})
|
||
continue
|
||
}
|
||
downloadedNames[strings.ToLower(filepath.Base(relativePath))] = true
|
||
downloads = append(downloads, map[string]any{
|
||
"resourceType": resourceType,
|
||
"resourceId": resourceID,
|
||
"messageId": messageID,
|
||
"localPath": filepath.ToSlash(relativePath),
|
||
"sizeBytes": size,
|
||
})
|
||
}
|
||
return map[string]any{
|
||
"ok": len(failures) == 0,
|
||
"partial": len(downloads) > 0 && len(failures) > 0,
|
||
"discoveredCount": discoveredCount,
|
||
"requestedCount": len(resources),
|
||
"deduplicatedCount": discoveredCount - len(resources),
|
||
"downloadedCount": len(downloads),
|
||
"failedCount": len(failures),
|
||
"downloads": downloads,
|
||
"failures": failures,
|
||
}
|
||
}
|
||
|
||
func disambiguateResourceDownloadFilename(filename string, used map[string]bool) string {
|
||
if !used[strings.ToLower(filename)] {
|
||
return filename
|
||
}
|
||
extension := filepath.Ext(filename)
|
||
stem := strings.TrimSuffix(filename, extension)
|
||
for sequence := 2; ; sequence++ {
|
||
candidate := fmt.Sprintf("%s (%d)%s", stem, sequence, extension)
|
||
if !used[strings.ToLower(candidate)] {
|
||
return candidate
|
||
}
|
||
}
|
||
}
|
||
|
||
func uniqueShortcutStrings(values []string) []string {
|
||
out := make([]string, 0, len(values))
|
||
for _, value := range values {
|
||
value = strings.TrimSpace(value)
|
||
if value != "" {
|
||
out = appendUniqueShortcutString(out, value)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// MessagesQuerySendStatus queries send status of a message (query_message_send_status, im).
|
||
var MessagesQuerySendStatus = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-query-send-status",
|
||
Product: "im",
|
||
Description: "查询消息发送状态",
|
||
Intent: "当你发消息后拿到 openTaskId、想确认这条消息是否发送成功时使用;只读返回发送状态,需传 --open-task-id。",
|
||
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_messages_query_send_status",
|
||
CanonicalPath: "chat.shortcut_messages_query_send_status",
|
||
CLIPath: "chat +messages-query-send-status",
|
||
PrimaryCLIPath: "chat +messages-query-send-status",
|
||
},
|
||
Description: "查询消息发送状态",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "查询消息发送状态",
|
||
UseWhen: []string{"当你发消息后拿到 openTaskId、想确认这条消息是否发送成功时使用;只读返回发送状态,需传 --open-task-id。"},
|
||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||
Examples: []string{"dws chat +messages-query-send-status --open-task-id <openTaskId>"},
|
||
},
|
||
},
|
||
Flags: []shortcut.Flag{
|
||
{Name: "open-task-id", Type: shortcut.FlagString, Desc: "发送消息时返回的 openTaskId", Required: true},
|
||
},
|
||
Tips: []string{`dws chat +messages-query-send-status --open-task-id <openTaskId>`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
return rt.CallMCP("query_message_send_status", map[string]any{"openTaskId": rt.Str("open-task-id")})
|
||
},
|
||
}
|
||
|
||
// MessagesReadStatus queries read/unread status of a message (query_msg_read_status, im).
|
||
var MessagesReadStatus = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-read-status",
|
||
Product: "im",
|
||
Description: "查询消息的已读/未读状态",
|
||
Intent: "当你想知道自己发出的某条消息有哪些人已读/未读时使用;只读,需传会话 openConversationId 和该消息 openMessageId,可指定目标成员列表。",
|
||
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_messages_read_status",
|
||
CanonicalPath: "chat.shortcut_messages_read_status",
|
||
CLIPath: "chat +messages-read-status",
|
||
PrimaryCLIPath: "chat +messages-read-status",
|
||
},
|
||
Description: "查询消息的已读/未读状态",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "查询消息的已读/未读状态",
|
||
UseWhen: []string{"当你想知道自己发出的某条消息有哪些人已读/未读时使用;只读,需传会话 openConversationId 和该消息 openMessageId,可指定目标成员列表。"},
|
||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||
Examples: []string{"dws chat +messages-read-status --conversation-id <openConversationId> --message-id <openMessageId>"},
|
||
},
|
||
},
|
||
Flags: []shortcut.Flag{
|
||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId"},
|
||
{Name: "group", Type: shortcut.FlagString, Desc: "--conversation-id 的别名", Hidden: true},
|
||
{Name: "id", Type: shortcut.FlagString, Desc: "--conversation-id 的别名", Hidden: true},
|
||
{Name: "message-id", Type: shortcut.FlagString, Desc: "消息 openMessageId(当前用户发送的消息)", Required: true},
|
||
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "目标 userId 或 openDingTalkId 列表(不传返回全部接收者)"},
|
||
},
|
||
Constraints: []shortcut.Constraint{
|
||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"conversation-id", "group", "id"}},
|
||
},
|
||
Tips: []string{`dws chat +messages-read-status --conversation-id <openConversationId> --message-id <openMessageId>`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
params := map[string]any{
|
||
"openConversationId": rt.StrFirst("conversation-id", "group", "id"),
|
||
"openMessageId": rt.Str("message-id"),
|
||
}
|
||
if v := rt.StrSlice("users"); len(v) > 0 {
|
||
userIDs, openIDs := splitIDs(v)
|
||
if len(userIDs) > 0 {
|
||
params["targetUserIds"] = userIDs
|
||
}
|
||
if len(openIDs) > 0 {
|
||
params["targetOpenDingTalkIds"] = openIDs
|
||
}
|
||
}
|
||
return rt.CallMCP("query_msg_read_status", params)
|
||
},
|
||
}
|
||
|
||
// MessagesAddEmoji adds an emoji reaction (add_emoji_reaction, im).
|
||
var MessagesAddEmoji = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-add-emoji",
|
||
Product: "im",
|
||
Description: "对消息添加 emoji 表情回应",
|
||
Intent: "当你想给某条消息点一个 emoji 表情回应时使用;会实际添加表情回应,需传会话 openConversationId、消息 openMsgId 和表情名称。",
|
||
Risk: shortcut.RiskWrite,
|
||
Flags: []shortcut.Flag{
|
||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||
{Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMsgId", Required: true},
|
||
{Name: "emoji", Type: shortcut.FlagString, Desc: "emoji 表情名称", Required: true},
|
||
},
|
||
Tips: []string{`dws chat +messages-add-emoji --conversation-id <openConversationId> --msg-id <openMsgId> --emoji "赞"`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
return rt.CallMCP("add_emoji_reaction", map[string]any{
|
||
"openConversationId": rt.Str("conversation-id"),
|
||
"openMsgId": rt.Str("msg-id"),
|
||
"emojiName": rt.Str("emoji"),
|
||
})
|
||
},
|
||
}
|
||
|
||
// MessagesRemoveEmoji removes an emoji reaction (remove_emoji_reaction, im).
|
||
var MessagesRemoveEmoji = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-remove-emoji",
|
||
Product: "im",
|
||
Description: "移除消息的 emoji 表情回应",
|
||
Intent: "当你想取消此前给某条消息添加的 emoji 表情回应时使用;会实际移除表情回应,需传会话 openConversationId、消息 openMsgId 和表情名称。",
|
||
Risk: shortcut.RiskWrite,
|
||
Flags: []shortcut.Flag{
|
||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||
{Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMsgId", Required: true},
|
||
{Name: "emoji", Type: shortcut.FlagString, Desc: "emoji 表情名称", Required: true},
|
||
},
|
||
Tips: []string{`dws chat +messages-remove-emoji --conversation-id <openConversationId> --msg-id <openMsgId> --emoji "赞"`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
return rt.CallMCP("remove_emoji_reaction", map[string]any{
|
||
"openConversationId": rt.Str("conversation-id"),
|
||
"openMsgId": rt.Str("msg-id"),
|
||
"emojiName": rt.Str("emoji"),
|
||
})
|
||
},
|
||
}
|
||
|
||
// MessagesAddTextEmotion adds a text emotion reaction (add_text_emotion, im).
|
||
var MessagesAddTextEmotion = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-add-text-emotion",
|
||
Product: "im",
|
||
Description: "对消息添加文字表情回应",
|
||
Intent: "当你想给某条消息添加自定义文字表情回应时使用;会实际添加文字表情,需传会话、消息 openMsgId 及由 create-text-emotion 得到的 emotionId 等参数。",
|
||
Risk: shortcut.RiskWrite,
|
||
Flags: []shortcut.Flag{
|
||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||
{Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMsgId", Required: true},
|
||
{Name: "emotion-id", Type: shortcut.FlagString, Desc: "表情 ID(由 create-text-emotion 获取)", Required: true},
|
||
{Name: "emotion-name", Type: shortcut.FlagString, Desc: "表情名称", Required: true},
|
||
{Name: "text", Type: shortcut.FlagString, Desc: "文字内容", Required: true},
|
||
{Name: "background-id", Type: shortcut.FlagString, Desc: "背景 ID", Required: true},
|
||
},
|
||
Tips: []string{`dws chat +messages-add-text-emotion --conversation-id <openConversationId> --msg-id <openMsgId> --emotion-id <id> --emotion-name "赞" --text "nice" --background-id im_bg_5`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
return rt.CallMCP("add_text_emotion", map[string]any{
|
||
"openConversationId": rt.Str("conversation-id"),
|
||
"openMsgId": rt.Str("msg-id"),
|
||
"emotionId": rt.Str("emotion-id"),
|
||
"emotionName": rt.Str("emotion-name"),
|
||
"text": rt.Str("text"),
|
||
"backgroundId": rt.Str("background-id"),
|
||
})
|
||
},
|
||
}
|
||
|
||
// MessagesRemoveTextEmotion removes a text emotion reaction (remove_text_emotion, im).
|
||
var MessagesRemoveTextEmotion = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-remove-text-emotion",
|
||
Product: "im",
|
||
Description: "移除消息的文字表情回应",
|
||
Intent: "当你想移除此前给某条消息添加的文字表情回应时使用;会实际移除文字表情,需传会话、消息 openMsgId 及对应的表情参数。",
|
||
Risk: shortcut.RiskWrite,
|
||
Flags: []shortcut.Flag{
|
||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||
{Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMsgId", Required: true},
|
||
{Name: "emotion-id", Type: shortcut.FlagString, Desc: "表情 ID", Required: true},
|
||
{Name: "emotion-name", Type: shortcut.FlagString, Desc: "表情名称", Required: true},
|
||
{Name: "text", Type: shortcut.FlagString, Desc: "文字内容", Required: true},
|
||
{Name: "background-id", Type: shortcut.FlagString, Desc: "背景 ID", Required: true},
|
||
},
|
||
Tips: []string{`dws chat +messages-remove-text-emotion --conversation-id <openConversationId> --msg-id <openMsgId> --emotion-id <id> --emotion-name "赞" --text "nice" --background-id im_bg_5`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
return rt.CallMCP("remove_text_emotion", map[string]any{
|
||
"openConversationId": rt.Str("conversation-id"),
|
||
"openMsgId": rt.Str("msg-id"),
|
||
"emotionId": rt.Str("emotion-id"),
|
||
"emotionName": rt.Str("emotion-name"),
|
||
"text": rt.Str("text"),
|
||
"backgroundId": rt.Str("background-id"),
|
||
})
|
||
},
|
||
}
|
||
|
||
// MessagesCreateTextEmotion creates a text emotion template (create_text_emotion, im).
|
||
var MessagesCreateTextEmotion = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-create-text-emotion",
|
||
Product: "im",
|
||
Description: "创建文字表情(获取 emotionId)",
|
||
Intent: "当你要先创建一个文字表情模板(拿到 emotionId 供 add-text-emotion 使用)时使用;会实际创建文字表情,需传表情名称和文字内容。",
|
||
Risk: shortcut.RiskWrite,
|
||
Flags: []shortcut.Flag{
|
||
{Name: "emotion-name", Type: shortcut.FlagString, Desc: "表情名称", Required: true},
|
||
{Name: "text", Type: shortcut.FlagString, Desc: "文字内容", Required: true},
|
||
{Name: "background-id", Type: shortcut.FlagString, Desc: "背景 ID(可选)"},
|
||
},
|
||
Tips: []string{`dws chat +messages-create-text-emotion --emotion-name "赞" --text "nice"`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
params := map[string]any{
|
||
"emotionName": rt.Str("emotion-name"),
|
||
"text": rt.Str("text"),
|
||
}
|
||
if rt.Changed("background-id") {
|
||
params["backgroundId"] = rt.Str("background-id")
|
||
}
|
||
return rt.CallMCP("create_text_emotion", params)
|
||
},
|
||
}
|
||
|
||
// MessagesSendCard creates and optionally completes a streaming card by
|
||
// composing create_and_send_card with update_streaming_card.
|
||
var MessagesSendCard = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-send-card",
|
||
Product: "im",
|
||
Description: "创建流式卡片,可在同一次调用中写入内容并结束",
|
||
Intent: "当你要发送一张流式卡片消息时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成,避免卡片停留在加载中。",
|
||
Risk: shortcut.RiskWrite,
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "user_required", Idempotency: "unknown",
|
||
},
|
||
Contract: corecmd.ContractDecl{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "chat",
|
||
Name: "shortcut_messages_send_card",
|
||
CanonicalPath: "chat.shortcut_messages_send_card",
|
||
CLIPath: "chat +messages-send-card",
|
||
PrimaryCLIPath: "chat +messages-send-card",
|
||
},
|
||
Description: "创建流式卡片,可在同一次调用中写入内容并结束",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "Reviewed card lifecycle adapter: it can resolve a userId through contact search with exact matching, call create_and_send_card alone, or compose creation with update_streaming_card after extracting the returned bizId.",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "创建流式卡片,可在同一次调用中写入内容并结束",
|
||
UseWhen: []string{"当你要发送一张流式卡片消息时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成,避免卡片停留在加载中。"},
|
||
AvoidWhen: []string{"已有 bizId、只需要追加或更新现有卡片内容时使用 +messages-update-card"},
|
||
Examples: []string{
|
||
"dws chat +messages-send-card --group <openConversationId> --content \"任务已完成\"",
|
||
"dws chat +messages-send-card --receiver <userId>",
|
||
},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "receiver-open-dingtalk-id", Property: "receiverOpenDingTalkId"},
|
||
},
|
||
},
|
||
Flags: []shortcut.Flag{
|
||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(与两个单聊接收者参数互斥)"},
|
||
{Name: "receiver", Type: shortcut.FlagString, Desc: "单聊接收者 userId(与 --group/--receiver-open-dingtalk-id 互斥);始终通过通讯录搜索精确匹配 openDingTalkId,包括 --dry-run 和 D/d 开头的 userId"},
|
||
{Name: "receiver-open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊接收者 openDingTalkId(与 --group/--receiver 互斥);显式直传且不做通讯录解析"},
|
||
{Name: "content", Type: shortcut.FlagString, Desc: "创建后立即写入的卡片内容;省略时仅创建并返回 bizId"},
|
||
{Name: "flow-status", Type: shortcut.FlagInt, Default: "3", Desc: "自动更新状态:1处理中/2输入中/3完成/4执行中/5错误;--flow-status 必须在 1-5 之间,且显式指定时必须同时提供 --content"},
|
||
},
|
||
Constraints: []shortcut.Constraint{
|
||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "receiver", "receiver-open-dingtalk-id"}},
|
||
{
|
||
Kind: shortcut.ConstraintCustom,
|
||
Flags: []string{"flow-status"},
|
||
Description: "--flow-status 必须在 1-5 之间,且显式指定时必须同时提供 --content",
|
||
},
|
||
},
|
||
Tips: []string{
|
||
`dws chat +messages-send-card --group <openConversationId>`,
|
||
`dws chat +messages-send-card --group <openConversationId> --content "任务已完成"`,
|
||
},
|
||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||
if status := rt.Int("flow-status"); status < 1 || status > 5 {
|
||
return fmt.Errorf("--flow-status 必须在 1-5 之间")
|
||
}
|
||
if rt.Changed("flow-status") && rt.Str("content") == "" {
|
||
return fmt.Errorf("--flow-status 只有与 --content 一起使用才有意义")
|
||
}
|
||
return nil
|
||
},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
group := rt.Str("group")
|
||
receiver := rt.Str("receiver")
|
||
receiverOpenID := rt.Str("receiver-open-dingtalk-id")
|
||
params := map[string]any{}
|
||
switch {
|
||
case group != "":
|
||
params["openConversationId"] = group
|
||
case receiver != "":
|
||
openID, err := resolveUserOpenDingTalkID(rt, receiver)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
params["receiverOpenDingTalkId"] = openID
|
||
default:
|
||
params["receiverOpenDingTalkId"] = receiverOpenID
|
||
}
|
||
content := rt.Str("content")
|
||
if content == "" {
|
||
return rt.CallMCP("create_and_send_card", params)
|
||
}
|
||
status := rt.Int("flow-status")
|
||
if rt.DryRun() {
|
||
return rt.Output(map[string]any{
|
||
"dry_run": true,
|
||
"executed": false,
|
||
"preview_kind": "plan",
|
||
"actionCount": 2,
|
||
"failedCount": 0,
|
||
"actions": []map[string]any{
|
||
{
|
||
"tool": "create_and_send_card",
|
||
"arguments": params,
|
||
},
|
||
{
|
||
"tool": "update_streaming_card",
|
||
"arguments": map[string]any{
|
||
"bizId": "<from create_and_send_card>",
|
||
"msgContent": content,
|
||
"flowStatus": status,
|
||
},
|
||
},
|
||
},
|
||
})
|
||
}
|
||
created, err := rt.CallMCPWriteData("im", "create_and_send_card", params)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
bizID := findCardBizID(created)
|
||
if bizID == "" {
|
||
return fmt.Errorf("卡片已创建但下层未返回 bizId,无法自动更新;请检查 create_and_send_card 响应")
|
||
}
|
||
updated, err := rt.CallMCPWriteData("im", "update_streaming_card", map[string]any{
|
||
"bizId": bizID,
|
||
"msgContent": content,
|
||
"flowStatus": status,
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("卡片已创建(bizId=%s),但自动更新失败: %w", bizID, err)
|
||
}
|
||
return rt.Output(map[string]any{
|
||
"ok": true,
|
||
"bizId": bizID,
|
||
"flowStatus": status,
|
||
"created": created,
|
||
"updated": updated,
|
||
})
|
||
},
|
||
}
|
||
|
||
func findCardBizID(value any) string {
|
||
switch typed := value.(type) {
|
||
case map[string]any:
|
||
directKeys := []string{"bizId", "bizID", "biz_id"}
|
||
for _, key := range directKeys {
|
||
if candidate, ok := typed[key].(string); ok && strings.TrimSpace(candidate) != "" {
|
||
candidate = strings.TrimSpace(candidate)
|
||
return candidate
|
||
}
|
||
}
|
||
|
||
// Prefer documented response envelopes before scanning extension fields.
|
||
// Map iteration order is deliberately randomized by Go, so an unordered
|
||
// recursive walk could select a stale metadata bizId.
|
||
envelopeKeys := []string{"result", "data", "card", "response"}
|
||
visited := make(map[string]struct{}, len(directKeys)+len(envelopeKeys))
|
||
for _, key := range directKeys {
|
||
visited[key] = struct{}{}
|
||
}
|
||
for _, key := range envelopeKeys {
|
||
visited[key] = struct{}{}
|
||
if candidate := findCardBizID(typed[key]); candidate != "" {
|
||
return candidate
|
||
}
|
||
}
|
||
|
||
remainingKeys := make([]string, 0, len(typed))
|
||
for key := range typed {
|
||
if _, ok := visited[key]; !ok {
|
||
remainingKeys = append(remainingKeys, key)
|
||
}
|
||
}
|
||
sort.Strings(remainingKeys)
|
||
for _, key := range remainingKeys {
|
||
if candidate := findCardBizID(typed[key]); candidate != "" {
|
||
return candidate
|
||
}
|
||
}
|
||
case []any:
|
||
for _, child := range typed {
|
||
if candidate := findCardBizID(child); candidate != "" {
|
||
return candidate
|
||
}
|
||
}
|
||
case string:
|
||
trimmed := strings.TrimSpace(typed)
|
||
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
|
||
var nested any
|
||
if json.Unmarshal([]byte(trimmed), &nested) == nil {
|
||
return findCardBizID(nested)
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// MessagesUpdateCard streams updated card content (update_streaming_card, im).
|
||
var MessagesUpdateCard = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-update-card",
|
||
Product: "im",
|
||
Description: "流式更新卡片内容(最后一次 --flow-status 应为 3)",
|
||
Intent: "当你要向已发送的流式卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus(最后一次应为 3 表示完成)。",
|
||
Risk: shortcut.RiskWrite,
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "user_required", Idempotency: "unknown",
|
||
},
|
||
Contract: corecmd.ContractDecl{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "chat",
|
||
Name: "shortcut_messages_update_card",
|
||
CanonicalPath: "chat.shortcut_messages_update_card",
|
||
CLIPath: "chat +messages-update-card",
|
||
PrimaryCLIPath: "chat +messages-update-card",
|
||
},
|
||
Description: "流式更新卡片内容(最后一次 --flow-status 应为 3)",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "流式更新卡片内容(最后一次 --flow-status 应为 3)",
|
||
UseWhen: []string{"当你要向已发送的流式卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus(最后一次应为 3 表示完成)。"},
|
||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||
Examples: []string{"dws chat +messages-update-card --biz-id <bizId> --content \"内容\" --flow-status 3"},
|
||
},
|
||
},
|
||
Flags: []shortcut.Flag{
|
||
{Name: "biz-id", Type: shortcut.FlagString, Desc: "send-card 返回的卡片业务 ID", Required: true},
|
||
{Name: "content", Type: shortcut.FlagString, Desc: "卡片消息内容", Required: true},
|
||
{Name: "flow-status", Type: shortcut.FlagInt, Desc: "流式状态 1处理中/2输入中/3完成/4执行中/5错误", Required: true},
|
||
},
|
||
Tips: []string{`dws chat +messages-update-card --biz-id <bizId> --content "内容" --flow-status 3`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
return rt.CallMCP("update_streaming_card", map[string]any{
|
||
"bizId": rt.Str("biz-id"),
|
||
"msgContent": rt.Str("content"),
|
||
"flowStatus": rt.Int("flow-status"),
|
||
})
|
||
},
|
||
}
|
||
|
||
// MessagesResourceURL gets a message resource download url (get_resource_download_url, im).
|
||
var MessagesResourceURL = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-resource-url",
|
||
Product: "im",
|
||
Description: "获取消息资源(图片/视频/语音)下载链接",
|
||
Intent: "当你想下载消息里的图片/视频/语音等资源时使用;只读换取临时下载链接,需传资源 mediaId、消息 openMessageId 和会话 openConversationId。",
|
||
Risk: shortcut.RiskRead,
|
||
Flags: []shortcut.Flag{
|
||
{Name: "type", Type: shortcut.FlagString, Default: "mediaId", Desc: "资源类型", Enum: []string{"mediaId"}},
|
||
{Name: "resource-id", Type: shortcut.FlagString, Desc: "资源 ID(消息中的 mediaId)", Required: true},
|
||
{Name: "message-id", Type: shortcut.FlagString, Desc: "消息 openMessageId"},
|
||
{Name: "msg-id", Type: shortcut.FlagString, Desc: "--message-id 的别名", Hidden: true},
|
||
{Name: "open-message-id", Type: shortcut.FlagString, Desc: "--message-id 的别名", Hidden: true},
|
||
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||
},
|
||
// message-id is required, but accept the natural aliases agents reach for
|
||
// (the message-list output field is openMessageId/msgId). Declared via a
|
||
// constraint rather than Required because a shortcut's Required check only
|
||
// looks at the primary flag name, so a hidden alias could not satisfy it.
|
||
Constraints: []shortcut.Constraint{
|
||
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"message-id", "msg-id", "open-message-id"}},
|
||
},
|
||
Tips: []string{`dws chat +messages-resource-url --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId>`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
return rt.CallMCP("get_resource_download_url", map[string]any{
|
||
"resourceType": rt.Str("type"),
|
||
"resourceId": rt.Str("resource-id"),
|
||
"openMessageId": rt.StrFirst("message-id", "msg-id", "open-message-id"),
|
||
"openConversationId": rt.Str("open-conversation-id"),
|
||
})
|
||
},
|
||
}
|
||
|
||
// MessagesForward forwards one message (forward_message, im).
|
||
var MessagesForward = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-forward",
|
||
Product: "im",
|
||
Description: "转发单条消息",
|
||
Intent: "当你想把一条消息转发到另一个会话时使用;会实际转发消息,需传源会话 openConversationId、源消息 openMessageId 和目标会话 openConversationId。",
|
||
Risk: shortcut.RiskWrite,
|
||
Flags: []shortcut.Flag{
|
||
{Name: "src-conversation-id", Type: shortcut.FlagString, Desc: "源会话 openConversationId", Required: true},
|
||
{Name: "msg-id", Type: shortcut.FlagString, Desc: "源消息 openMessageId", Required: true},
|
||
{Name: "dest-conversation-id", Type: shortcut.FlagString, Desc: "目标会话 openConversationId", Required: true},
|
||
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键(可选)"},
|
||
},
|
||
Tips: []string{`dws chat +messages-forward --src-conversation-id <srcCid> --msg-id <msgId> --dest-conversation-id <destCid>`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
params := map[string]any{
|
||
"srcOpenCid": rt.Str("src-conversation-id"),
|
||
"srcOpenMessageId": rt.Str("msg-id"),
|
||
"destOpenCid": rt.Str("dest-conversation-id"),
|
||
}
|
||
if rt.Changed("uuid") {
|
||
params["uuid"] = rt.Str("uuid")
|
||
}
|
||
return rt.CallMCP("forward_message", params)
|
||
},
|
||
}
|
||
|
||
// MessagesCombineForward merge-forwards multiple messages (combine_forward_messages, im).
|
||
var MessagesCombineForward = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-combine-forward",
|
||
Product: "im",
|
||
Description: "合并转发多条消息",
|
||
Intent: "当你想把多条消息合并成一条转发到目标会话时使用;会实际合并转发,需传源会话、源消息 openMessageId 列表和目标会话 openConversationId。",
|
||
Risk: shortcut.RiskWrite,
|
||
Flags: []shortcut.Flag{
|
||
{Name: "src-conversation-id", Type: shortcut.FlagString, Desc: "源会话 openConversationId", Required: true},
|
||
{Name: "msg-ids", Type: shortcut.FlagStringSlice, Desc: "源消息 openMessageId 列表", Required: true},
|
||
{Name: "dest-conversation-id", Type: shortcut.FlagString, Desc: "目标会话 openConversationId", Required: true},
|
||
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键(可选)"},
|
||
},
|
||
Tips: []string{`dws chat +messages-combine-forward --src-conversation-id <srcCid> --msg-ids id1,id2 --dest-conversation-id <destCid>`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
params := map[string]any{
|
||
"srcOpenCid": rt.Str("src-conversation-id"),
|
||
"srcOpenMessageIds": rt.StrSlice("msg-ids"),
|
||
"destOpenCid": rt.Str("dest-conversation-id"),
|
||
}
|
||
if rt.Changed("uuid") {
|
||
params["uuid"] = rt.Str("uuid")
|
||
}
|
||
return rt.CallMCP("combine_forward_messages", params)
|
||
},
|
||
}
|
||
|
||
// MessagesForwardTopic forwards a topic message (forward_topic, im).
|
||
var MessagesForwardTopic = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-forward-topic",
|
||
Product: "im",
|
||
Description: "转发话题消息到目标会话",
|
||
Intent: "当你要把某条群话题消息转发到目标会话时使用;会实际转发话题消息,需传源消息、源会话、话题 threadId 和目标会话 openConversationId。",
|
||
Risk: shortcut.RiskWrite,
|
||
Flags: []shortcut.Flag{
|
||
{Name: "src-msg-id", Type: shortcut.FlagString, Desc: "源消息 openMessageId", Required: true},
|
||
{Name: "src-conversation-id", Type: shortcut.FlagString, Desc: "源会话 openConversationId", Required: true},
|
||
{Name: "src-thread-id", Type: shortcut.FlagString, Desc: "话题 ID(convThread + 加密 convThreadId)", Required: true},
|
||
{Name: "dest-conversation-id", Type: shortcut.FlagString, Desc: "目标会话 openConversationId", Required: true},
|
||
},
|
||
Tips: []string{`dws chat +messages-forward-topic --src-msg-id <msgId> --src-conversation-id <srcCid> --src-thread-id <threadId> --dest-conversation-id <destCid>`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
return rt.CallMCP("forward_topic", map[string]any{
|
||
"srcOpenMessageId": rt.Str("src-msg-id"),
|
||
"srcOpenConversationId": rt.Str("src-conversation-id"),
|
||
"srcOpenConvThreadId": rt.Str("src-thread-id"),
|
||
"destOpenConversationId": rt.Str("dest-conversation-id"),
|
||
})
|
||
},
|
||
}
|
||
|
||
// MessagesSetPin pins a message (set_pin_message, im).
|
||
var MessagesSetPin = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-set-pin",
|
||
Product: "im",
|
||
Description: "钉住消息(Pin)",
|
||
Intent: "当你想把某条消息钉在会话中(Pin)以便成员随时查看时使用;会实际钉住消息,需传会话 openConversationId 和消息 openMessageId。",
|
||
Risk: shortcut.RiskWrite,
|
||
Flags: []shortcut.Flag{
|
||
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||
{Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true},
|
||
},
|
||
Tips: []string{`dws chat +messages-set-pin --open-conversation-id <openConversationId> --msg-id <openMessageId>`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
return rt.CallMCP("set_pin_message", map[string]any{
|
||
"openConversationId": rt.Str("open-conversation-id"),
|
||
"cid": rt.Str("open-conversation-id"),
|
||
"openMessageId": rt.Str("msg-id"),
|
||
})
|
||
},
|
||
}
|
||
|
||
// MessagesUnsetPin unpins a message (unset_pin_message, im).
|
||
var MessagesUnsetPin = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-unset-pin",
|
||
Product: "im",
|
||
Description: "取消钉住消息(Unpin)",
|
||
Intent: "当你想取消此前钉住的消息时使用;会实际取消 Pin,需传会话 openConversationId 和消息 openMessageId。",
|
||
Risk: shortcut.RiskWrite,
|
||
Flags: []shortcut.Flag{
|
||
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||
{Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true},
|
||
},
|
||
Tips: []string{`dws chat +messages-unset-pin --open-conversation-id <openConversationId> --msg-id <openMessageId>`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
return rt.CallMCP("unset_pin_message", map[string]any{
|
||
"openConversationId": rt.Str("open-conversation-id"),
|
||
"cid": rt.Str("open-conversation-id"),
|
||
"openMessageId": rt.Str("msg-id"),
|
||
})
|
||
},
|
||
}
|
||
|
||
// MessagesListPin lists pinned messages (list_pin_messages, im).
|
||
var MessagesListPin = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-list-pin",
|
||
Product: "im",
|
||
Description: "拉取会话中钉住的消息列表",
|
||
Intent: "当你想查看某会话里当前钉住的消息有哪些时使用;只读分页返回,需传会话 openConversationId。",
|
||
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_messages_list_pin",
|
||
CanonicalPath: "chat.shortcut_messages_list_pin",
|
||
CLIPath: "chat +messages-list-pin",
|
||
PrimaryCLIPath: "chat +messages-list-pin",
|
||
},
|
||
Description: "拉取会话中钉住的消息列表",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "拉取会话中钉住的消息列表",
|
||
UseWhen: []string{"当你想查看某会话里当前钉住的消息有哪些时使用;只读分页返回,需传会话 openConversationId。"},
|
||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||
Examples: []string{"dws chat +messages-list-pin --open-conversation-id <openConversationId>"},
|
||
},
|
||
},
|
||
Flags: []shortcut.Flag{
|
||
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传 nextCursor"},
|
||
{Name: "size", Type: shortcut.FlagInt, Desc: "一次拉取的消息数量(默认 20,最大 100)"},
|
||
},
|
||
Tips: []string{`dws chat +messages-list-pin --open-conversation-id <openConversationId>`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
params := map[string]any{
|
||
"openConversationId": rt.Str("open-conversation-id"),
|
||
"cid": rt.Str("open-conversation-id"),
|
||
}
|
||
if rt.Changed("cursor") {
|
||
params["cursor"] = rt.Str("cursor")
|
||
}
|
||
if rt.Int("size") > 0 {
|
||
params["count"] = rt.Int("size")
|
||
}
|
||
data, err := rt.CallMCPData("im", "list_pin_messages", params)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
pins := listPinProject(data)
|
||
payload := map[string]any{"count": len(pins), "pins": pins}
|
||
chatmsg.ApplyPagination(payload, data)
|
||
return rt.Output(payload)
|
||
},
|
||
}
|
||
|
||
// listPinProject reshapes the raw list_pin_messages response into a clean
|
||
// {messageId, senderId, pinTime, conversationId} list — output-projection
|
||
// clean output projection. Both the list container and per-item field names are probed
|
||
// defensively across candidate keys, so an empty or unexpected shape yields an
|
||
// empty list rather than a crash or fabricated data.
|
||
func listPinProject(data map[string]any) []map[string]any {
|
||
raw := listPinResolveList(data)
|
||
out := make([]map[string]any, 0, len(raw))
|
||
for _, item := range raw {
|
||
m, ok := item.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
row := map[string]any{}
|
||
if v, ok := listPinFirst(m, "openMessageId", "openMsgId", "messageId", "msgId"); ok {
|
||
row["messageId"] = v
|
||
}
|
||
if v, ok := listPinFirst(m, "senderOpenDingTalkId", "senderUserId", "operatorId", "senderId"); ok {
|
||
row["senderId"] = v
|
||
}
|
||
if v, ok := listPinFirst(m, "pinTime", "createTime", "gmtCreate", "operateTime"); ok {
|
||
row["pinTime"] = v
|
||
}
|
||
if v, ok := listPinFirst(m, "openConversationId", "conversationId", "openConvId"); ok {
|
||
row["conversationId"] = v
|
||
}
|
||
if threadID := chatmsg.ThreadID(m); threadID != nil {
|
||
row["threadId"] = threadID
|
||
}
|
||
if len(row) > 0 {
|
||
out = append(out, row)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// listPinResolveList locates the list payload, tolerating a bare top-level array
|
||
// container or nesting one level deeper under a common envelope key.
|
||
func listPinResolveList(data map[string]any) []any {
|
||
for _, key := range []string{"result", "data", "list", "items", "pinMessages", "pinnedMessages", "messages"} {
|
||
v, ok := data[key]
|
||
if !ok {
|
||
continue
|
||
}
|
||
if arr, ok := v.([]any); ok {
|
||
return arr
|
||
}
|
||
if inner, ok := v.(map[string]any); ok {
|
||
for _, ik := range []string{"list", "items", "pinMessages", "pinnedMessages", "messages", "result", "data"} {
|
||
if arr, ok := inner[ik].([]any); ok {
|
||
return arr
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return []any{}
|
||
}
|
||
|
||
// listPinFirst returns the first present candidate key's value.
|
||
func listPinFirst(m map[string]any, keys ...string) (any, bool) {
|
||
for _, k := range keys {
|
||
if v, ok := m[k]; ok {
|
||
return v, true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
// MessagesSetTop pins a message to the top (set_top_message, im).
|
||
var MessagesSetTop = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-set-top",
|
||
Product: "im",
|
||
Description: "置顶消息",
|
||
Intent: "当你想把某条消息置顶到会话顶部时使用;会实际置顶消息,需传会话 openConversationId 和消息 openMessageId。",
|
||
Risk: shortcut.RiskWrite,
|
||
Flags: []shortcut.Flag{
|
||
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||
{Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true},
|
||
},
|
||
Tips: []string{`dws chat +messages-set-top --open-conversation-id <openConversationId> --msg-id <openMessageId>`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
return rt.CallMCP("set_top_message", map[string]any{
|
||
"openConversationId": rt.Str("open-conversation-id"),
|
||
"openMessageId": rt.Str("msg-id"),
|
||
})
|
||
},
|
||
}
|
||
|
||
// MessagesUnsetTop cancels a message top (unset_top_message, im).
|
||
var MessagesUnsetTop = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-unset-top",
|
||
Product: "im",
|
||
Description: "取消置顶消息",
|
||
Intent: "当你想取消此前置顶的消息时使用;会实际取消置顶,需传会话 openConversationId 和消息 openMessageId。",
|
||
Risk: shortcut.RiskWrite,
|
||
Flags: []shortcut.Flag{
|
||
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||
{Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true},
|
||
},
|
||
Tips: []string{`dws chat +messages-unset-top --open-conversation-id <openConversationId> --msg-id <openMessageId>`},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
return rt.CallMCP("unset_top_message", map[string]any{
|
||
"openConversationId": rt.Str("open-conversation-id"),
|
||
"openMessageId": rt.Str("msg-id"),
|
||
})
|
||
},
|
||
}
|
||
|
||
func init() {
|
||
shortcut.Register(
|
||
MessagesSendByBot,
|
||
MessagesBatchSendByBot,
|
||
MessagesSendByWebhook,
|
||
MessagesRecall,
|
||
MessagesRecallByBot,
|
||
MessagesBatchRecallByBot,
|
||
MessagesList,
|
||
MessagesListDirect,
|
||
MessagesListUnreadConversations,
|
||
MessagesMget,
|
||
MessagesQuerySendStatus,
|
||
MessagesReadStatus,
|
||
MessagesAddEmoji,
|
||
MessagesRemoveEmoji,
|
||
MessagesAddTextEmotion,
|
||
MessagesRemoveTextEmotion,
|
||
MessagesCreateTextEmotion,
|
||
MessagesSendCard,
|
||
MessagesUpdateCard,
|
||
MessagesResourceURL,
|
||
MessagesForward,
|
||
MessagesCombineForward,
|
||
MessagesForwardTopic,
|
||
MessagesSetPin,
|
||
MessagesUnsetPin,
|
||
MessagesListPin,
|
||
MessagesSetTop,
|
||
MessagesUnsetTop,
|
||
)
|
||
}
|