Files
dingtalk-workspace-cli/internal/shortcut/chat/chat_message.go

1769 lines
77 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 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,
)
}