248 lines
9.6 KiB
Go
248 lines
9.6 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 smart
|
|
|
|
import (
|
|
"strconv"
|
|
"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"
|
|
)
|
|
|
|
// UnreadChats: list MY conversations that currently have unread messages in one
|
|
// step.
|
|
//
|
|
// It calls unread_message_conversation_list on the chat server — the exact tool
|
|
// and parameter names used by helpers.chatMessageListUnreadConversationsCmd. The
|
|
// helper passes count (int) only when > 0 and excludeMuted (bool) only when
|
|
// true, so both are optional; this shortcut mirrors that: --count controls how
|
|
// many conversations to return (0 / unset uses the server default) and
|
|
// --exclude-muted drops chats you have muted.
|
|
//
|
|
// The returned conversations are then defensively projected down to
|
|
// {name, unread, conversationId} (multiple candidate keys per field). When the
|
|
// response carries no recognisable conversation list we fall back to printing
|
|
// the raw payload via rt.Output so it still honours --format/--jq/--fields.
|
|
//
|
|
// Read-only: it only lists and reshapes, it never marks anything read/unread.
|
|
//
|
|
// dws chat +unread-chats
|
|
// dws chat +unread-chats --count 20
|
|
// dws chat +unread-chats --exclude-muted
|
|
var UnreadChats = shortcut.Shortcut{
|
|
Service: "chat",
|
|
Command: "+unread-chats",
|
|
Product: "chat",
|
|
Description: "列出我有未读消息的会话(投影会话名/未读数/会话ID)",
|
|
Intent: "当你想快速看清自己当前有哪些会话还有未读消息、方便逐个处理时使用;" +
|
|
"内部调用未读会话列表接口,可用 --count 控制返回的会话条数(不传则用服务端默认值)," +
|
|
"用 --exclude-muted 排除你已设置免打扰的会话;再在本地把每个会话投影成会话名、未读数和会话 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_unread_chats",
|
|
CanonicalPath: "chat.shortcut_unread_chats",
|
|
CLIPath: "chat +unread-chats",
|
|
PrimaryCLIPath: "chat +unread-chats",
|
|
},
|
|
Description: "列出我有未读消息的会话(投影会话名/未读数/会话ID)",
|
|
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)",
|
|
UseWhen: []string{"当你想快速看清自己当前有哪些会话还有未读消息、方便逐个处理时使用;内部调用未读会话列表接口,可用 --count 控制返回的会话条数(不传则用服务端默认值),用 --exclude-muted 排除你已设置免打扰的会话;再在本地把每个会话投影成会话名、未读数和会话 ID 三个关键字段。这是纯只读操作,只做列表与本地投影,不会把任何会话标记为已读或未读;若没有未读会话则返回空列表。"},
|
|
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
|
Examples: []string{
|
|
"dws chat +unread-chats",
|
|
"dws chat +unread-chats --count 20",
|
|
},
|
|
},
|
|
},
|
|
Flags: []shortcut.Flag{
|
|
{Name: "count", Type: shortcut.FlagInt, Desc: "返回未读会话条数;显式 --count 必须大于 0,不传则使用服务端默认值", Required: false},
|
|
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "是否排除已设置免打扰的会话(可选,默认 false)", Required: false},
|
|
},
|
|
Constraints: []shortcut.Constraint{
|
|
{Kind: shortcut.ConstraintCustom, Flags: []string{"count"}, Description: "显式 --count 必须大于 0"},
|
|
},
|
|
Tips: []string{
|
|
`dws chat +unread-chats`,
|
|
`dws chat +unread-chats --count 20`,
|
|
`dws chat +unread-chats --exclude-muted`,
|
|
},
|
|
Validate: func(rt *shortcut.RuntimeContext) error {
|
|
if rt.Changed("count") && rt.Int("count") <= 0 {
|
|
return localChatOptionError("invalid_page_size", "+unread-chats 的 --count 必须大于 0", "--count")
|
|
}
|
|
return nil
|
|
},
|
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
|
// Build params exactly like chatMessageListUnreadConversationsCmd: count is
|
|
// only sent when > 0, excludeMuted only when true.
|
|
toolArgs := map[string]any{}
|
|
if count := rt.Int("count"); count > 0 {
|
|
toolArgs["count"] = count
|
|
}
|
|
if rt.Bool("exclude-muted") {
|
|
toolArgs["excludeMuted"] = true
|
|
}
|
|
|
|
data, err := rt.CallMCPData("chat", "unread_message_conversation_list", toolArgs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Project each conversation; fall back to the raw payload when we cannot
|
|
// locate a recognisable conversation list.
|
|
items := unreadChatItems(data)
|
|
if len(items) == 0 {
|
|
return rt.Output(data)
|
|
}
|
|
results := make([]map[string]any, 0, len(items))
|
|
for _, m := range items {
|
|
row := map[string]any{
|
|
"name": unreadChatName(m),
|
|
"conversationId": unreadChatConversationID(m),
|
|
}
|
|
// unread_message_conversation_list does not return a per-conversation
|
|
// unread count (presence in the list already implies unread>0), so
|
|
// only surface "unread" when the gateway actually provides it — avoid
|
|
// emitting a noisy null on every row.
|
|
if u := unreadChatUnread(m); u != nil {
|
|
row["unread"] = u
|
|
}
|
|
results = append(results, row)
|
|
}
|
|
return rt.Output(map[string]any{"conversations": results})
|
|
},
|
|
}
|
|
|
|
// unreadChatItems locates the conversation list inside an
|
|
// unread_message_conversation_list response, probing common container keys at
|
|
// the top level and nested under "result"/"data". Returns nil when no list is
|
|
// found.
|
|
func unreadChatItems(data map[string]any) []map[string]any {
|
|
if data == nil {
|
|
return nil
|
|
}
|
|
keys := []string{"list", "conversations", "conversationList", "unreadConversations", "items", "data", "records", "result"}
|
|
for _, key := range keys {
|
|
if arr, ok := data[key].([]any); ok {
|
|
return unreadChatToMaps(arr)
|
|
}
|
|
if inner, ok := data[key].(map[string]any); ok {
|
|
for _, k2 := range []string{"list", "conversations", "conversationList", "unreadConversations", "items", "data", "records"} {
|
|
if arr, ok := inner[k2].([]any); ok {
|
|
return unreadChatToMaps(arr)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func unreadChatToMaps(arr []any) []map[string]any {
|
|
out := make([]map[string]any, 0, len(arr))
|
|
for _, it := range arr {
|
|
if m, ok := it.(map[string]any); ok {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// unreadChatName reads a conversation's display title, preferring a readable
|
|
// name and falling back to a nested conversation object.
|
|
func unreadChatName(m map[string]any) any {
|
|
for _, key := range []string{"conversationTitle", "title", "conversationName", "groupName", "chatTitle", "name", "showName"} {
|
|
if v := unreadChatString(m[key]); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
for _, key := range []string{"conversation", "chat", "group"} {
|
|
if nested, ok := m[key].(map[string]any); ok {
|
|
for _, k2 := range []string{"title", "name", "conversationTitle", "groupName", "showName"} {
|
|
if v := unreadChatString(nested[k2]); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// unreadChatUnread reads a conversation's unread message count, returning the
|
|
// raw value under whichever candidate key is present.
|
|
func unreadChatUnread(m map[string]any) any {
|
|
for _, key := range []string{"unreadCount", "unread", "unReadCount", "unreadNum", "redPoint", "count"} {
|
|
if v, ok := m[key]; ok && v != nil {
|
|
return v
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// unreadChatConversationID reads the conversation id, tolerating the common id
|
|
// keys the gateway may use.
|
|
func unreadChatConversationID(m map[string]any) any {
|
|
for _, key := range []string{"openConversationId", "conversationId", "conversation_id", "cid", "openCid", "chatId"} {
|
|
if v := unreadChatString(m[key]); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
for _, key := range []string{"conversation", "chat"} {
|
|
if nested, ok := m[key].(map[string]any); ok {
|
|
for _, k2 := range []string{"openConversationId", "conversationId", "cid", "openCid"} {
|
|
if v := unreadChatString(nested[k2]); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// unreadChatString coerces a scalar JSON value to a trimmed string, returning ""
|
|
// for nil / non-scalar / empty values.
|
|
func unreadChatString(v any) string {
|
|
switch typed := v.(type) {
|
|
case string:
|
|
return strings.TrimSpace(typed)
|
|
case float64:
|
|
return strconv.FormatFloat(typed, 'f', -1, 64)
|
|
case int:
|
|
return strconv.Itoa(typed)
|
|
case int64:
|
|
return strconv.FormatInt(typed, 10)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func init() {
|
|
shortcut.Register(UnreadChats)
|
|
}
|