830 lines
32 KiB
Go
830 lines
32 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.
|
||
|
||
package chat
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
"unicode/utf8"
|
||
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||
)
|
||
|
||
const messagesSendFileUploadTimeout = 10 * time.Minute
|
||
|
||
const (
|
||
messagesSendMaxBotGroups = 100
|
||
messagesSendMaxGroupFileSize = 1 << 20
|
||
)
|
||
|
||
var messagesSendReadGroupFile = os.ReadFile
|
||
|
||
// MessagesSend is the identity-aware common sending entry point. The current
|
||
// user branch reuses the native message leaf's reviewed file-upload flow and
|
||
// existing-mediaId image path. Bot and webhook remain text/Markdown-only
|
||
// because their lower transports do not expose equivalent media contracts.
|
||
var MessagesSend = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+messages-send",
|
||
Product: "chat",
|
||
Description: "按身份和目标统一发送消息,Bot 多群返回逐目标 ledger",
|
||
Intent: "当你需要文件、复杂 @、幂等,或选择 current-user、bot、webhook 身份发送消息时使用;current-user 可直接传稳定 ID,也可用 --user-query/--chat-query 在 CLI 内唯一解析自然目标,dry-run 与真实执行使用同一解析链。Bot 可用 --groups/--groups-file 向最多 100 个稳定群 ID 发送文本或 Markdown,去重后返回 im.batch-write.v1 逐目标 ledger;webhook 目标由 token 所在群决定。文件上传和已有 mediaId 图片仅 current-user 支持,bot/webhook 不支持富媒体。",
|
||
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",
|
||
CanonicalPath: "chat.shortcut_messages_send",
|
||
CLIPath: "chat +messages-send",
|
||
PrimaryCLIPath: "chat +messages-send",
|
||
},
|
||
Description: "按身份和目标统一发送消息,Bot 多群返回逐目标 ledger",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "Reviewed composite send adapter: it selects current-user, bot, or webhook transport; current-user additionally supports live-compatible contact search with exact userId matching, mediaId images, and the native init/upload/commit local-file flow.",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "按身份和目标统一发送消息,Bot 多群返回逐目标 ledger",
|
||
UseWhen: []string{"当你需要文件、复杂 @、幂等,或选择 current-user、bot、webhook 身份发送消息时使用;current-user 可直接传稳定 ID,也可用 --user-query/--chat-query 在 CLI 内唯一解析自然目标,dry-run 与真实执行使用同一解析链。Bot 可用 --groups/--groups-file 向最多 100 个稳定群 ID 发送文本或 Markdown,去重后返回 im.batch-write.v1 逐目标 ledger;webhook 目标由 token 所在群决定。文件上传和已有 mediaId 图片仅 current-user 支持,bot/webhook 不支持富媒体。"},
|
||
AvoidWhen: []string{"需要 bot/webhook 发送媒体、卡片或 thread 回复时不要假设等价支持;改用真实存在的专用下层命令,缺少下层能力时停止"},
|
||
Examples: []string{
|
||
"dws chat +messages-send --as user --chat-id <openConversationId> --markdown \"## 周报\" --idempotency-key <key>",
|
||
"dws chat +messages-send --as user --user <userId> --msg-type file --file ./report.pdf",
|
||
},
|
||
},
|
||
},
|
||
Flags: []shortcut.Flag{
|
||
{Name: "identity", Type: shortcut.FlagString, Default: "user", Enum: []string{"user", "bot", "webhook"}, Desc: "发送身份;目标、凭据和幂等参数受发送身份能力矩阵约束"},
|
||
{Name: "as", Type: shortcut.FlagString, Enum: []string{"user", "bot", "webhook"}, Desc: "--identity 的 lark-cli 对齐别名;受发送身份能力矩阵约束"},
|
||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(user/bot 群聊);受发送身份能力矩阵约束"},
|
||
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的 lark-cli 对齐别名;受发送身份能力矩阵约束"},
|
||
{Name: "groups", Type: shortcut.FlagStringSlice, Desc: "多个群 openConversationId(仅 bot;受发送身份能力矩阵约束,逐群返回 typed ledger,最多 100 个)"},
|
||
{Name: "groups-file", Type: shortcut.FlagString, Desc: "工作目录内相对文本文件(仅 bot;受发送身份能力矩阵约束),每行或逗号分隔一个群 openConversationId"},
|
||
{Name: "chat-query", Type: shortcut.FlagString, Desc: "按群名解析唯一群聊(仅 user 的高级发送场景);受发送身份能力矩阵约束"},
|
||
{Name: "user", Type: shortcut.FlagString, Desc: "单聊接收者 userId(user;包括 --dry-run 也会先通过通讯录搜索精确匹配 openDingTalkId);受发送身份能力矩阵约束"},
|
||
{Name: "user-query", Type: shortcut.FlagString, Desc: "按姓名解析唯一 openDingTalkId(仅 user 的高级发送场景);受发送身份能力矩阵约束"},
|
||
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊接收者 openDingTalkId(user);受发送身份能力矩阵约束"},
|
||
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 userId(bot);受发送身份能力矩阵约束"},
|
||
{Name: "open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 openDingTalkId(bot);受发送身份能力矩阵约束"},
|
||
// Keep required_when out of Schema: validateMessagesSend already enforces
|
||
// bot/webhook credentials, and publishing RequiredWhen breaks merge-base
|
||
// schema-compatibility (null → expression).
|
||
{Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code(identity=bot 时使用);受发送身份能力矩阵约束"},
|
||
{Name: "webhook-token", Type: shortcut.FlagString, Desc: "自定义机器人 Webhook token(identity=webhook 时使用);受发送身份能力矩阵约束"},
|
||
{Name: "title", Type: shortcut.FlagString, Desc: "消息标题(不传则从正文生成)"},
|
||
{Name: "text", Type: shortcut.FlagString, Desc: "纯文本正文"},
|
||
{Name: "markdown", Type: shortcut.FlagString, Desc: "Markdown 正文"},
|
||
{Name: "msg-type", Type: shortcut.FlagString, Enum: []string{"text", "markdown", "image", "file", "audio", "video"}, Desc: "内容类型;省略时根据正文、--media-id 或 --file 自动推断"},
|
||
{Name: "media-id", Type: shortcut.FlagString, Desc: "已有图片 mediaId(仅 user 的 image)"},
|
||
{Name: "file", Type: shortcut.FlagString, Desc: "工作目录内安全相对文件路径(仅 user 的 file/audio/video)"},
|
||
{Name: "file-path", Type: shortcut.FlagString, Desc: "--file 的兼容别名"},
|
||
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键(仅 user);受发送身份能力矩阵约束"},
|
||
{Name: "idempotency-key", Type: shortcut.FlagString, Desc: "--uuid 的 lark-cli 对齐别名(仅 user);受发送身份能力矩阵约束"},
|
||
{Name: "at-open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "@ 的 openDingTalkId(user/bot 群聊)"},
|
||
{Name: "at-user-ids", Type: shortcut.FlagStringSlice, Desc: "@ 的 userId(bot/webhook)"},
|
||
{Name: "at-mobiles", Type: shortcut.FlagStringSlice, Desc: "@ 的手机号(webhook)"},
|
||
{Name: "at-all", Type: shortcut.FlagBool, Desc: "@所有人"},
|
||
shortcut.AIMessageTagFlag(),
|
||
},
|
||
Constraints: []shortcut.Constraint{
|
||
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"text", "markdown", "media-id", "file", "file-path"}},
|
||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"text", "markdown", "media-id", "file", "file-path"}},
|
||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"identity", "as"}},
|
||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"group", "chat-id"}},
|
||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"user", "open-dingtalk-id"}},
|
||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"uuid", "idempotency-key"}},
|
||
{
|
||
Kind: shortcut.ConstraintCustom,
|
||
Flags: []string{"identity", "as", "group", "chat-id", "groups", "groups-file", "chat-query", "user", "user-query", "open-dingtalk-id", "users", "open-dingtalk-ids", "robot-code", "webhook-token", "uuid", "idempotency-key"},
|
||
Description: "目标、凭据和幂等参数受发送身份能力矩阵约束:user 必须指定一个群聊或单聊目标;bot 必须指定 robot-code 和一类目标,多群最多 100 个并逐项返回 ledger;webhook 必须指定 webhook-token;幂等键仅 user 支持",
|
||
},
|
||
},
|
||
Tips: []string{
|
||
`dws chat +messages-send --as user --chat-id <openConversationId> --markdown "## 周报" --idempotency-key <key>`,
|
||
`dws chat +messages-send --as user --user <userId> --msg-type file --file ./report.pdf --idempotency-key <key>`,
|
||
`dws chat +messages-send --as bot --robot-code <robotCode> --groups <openConversationId1>,<openConversationId2> --text "请提交周报"`,
|
||
},
|
||
Validate: validateMessagesSend,
|
||
Execute: executeMessagesSend,
|
||
}
|
||
|
||
func validateMessagesSend(rt *shortcut.RuntimeContext) error {
|
||
identity := messagesSendIdentity(rt)
|
||
group := rt.StrFirst("chat-id", "group")
|
||
botGroups, botGroupsErr := messagesSendBotGroups(rt)
|
||
if botGroupsErr != nil {
|
||
return botGroupsErr
|
||
}
|
||
chatQuery := rt.Str("chat-query")
|
||
userID := rt.Str("user")
|
||
userQuery := rt.Str("user-query")
|
||
openID := rt.Str("open-dingtalk-id")
|
||
users := uniqueShortcutStrings(rt.StrSlice("users"))
|
||
openIDs := uniqueShortcutStrings(rt.StrSlice("open-dingtalk-ids"))
|
||
atOpenIDs := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids"))
|
||
atUserIDs := uniqueShortcutStrings(rt.StrSlice("at-user-ids"))
|
||
atMobiles := uniqueShortcutStrings(rt.StrSlice("at-mobiles"))
|
||
if openID != "" {
|
||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := validateExplicitOpenIDs("--open-dingtalk-ids", openIDs); err != nil {
|
||
return err
|
||
}
|
||
if err := validateExplicitOpenIDs("--at-open-dingtalk-ids", atOpenIDs); err != nil {
|
||
return err
|
||
}
|
||
contentType, err := messagesSendContentType(rt)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if contentType == "text" && rt.Str("markdown") != "" {
|
||
return apperrors.NewValidation("--msg-type text 必须与 --text 一起使用")
|
||
}
|
||
if contentType == "markdown" && rt.Str("text") != "" {
|
||
return apperrors.NewValidation("--msg-type markdown 必须与 --markdown 一起使用")
|
||
}
|
||
switch identity {
|
||
case "user":
|
||
targetCount := nonEmptyStringCount(group, chatQuery, userID, userQuery, openID)
|
||
if targetCount != 1 {
|
||
return apperrors.NewValidation("--identity user 时 --group/--chat-id、--chat-query、--user、--user-query、--open-dingtalk-id 必须且只能指定一个")
|
||
}
|
||
if len(users) > 0 || len(openIDs) > 0 || len(botGroups) > 0 || rt.Str("robot-code") != "" || rt.Str("webhook-token") != "" {
|
||
return apperrors.NewValidation("--identity user 不接受 bot/webhook 凭据或批量目标")
|
||
}
|
||
if len(atUserIDs) > 0 || len(atMobiles) > 0 {
|
||
return apperrors.NewValidation("--identity user 只接受 --at-open-dingtalk-ids")
|
||
}
|
||
if (userID != "" || userQuery != "" || openID != "") && (len(atOpenIDs) > 0 || rt.Bool("at-all")) {
|
||
return apperrors.NewValidation("user 单聊不接受 @ 参数;@ 只适用于群聊")
|
||
}
|
||
if contentType != "text" && contentType != "markdown" && (len(atOpenIDs) > 0 || rt.Bool("at-all")) {
|
||
return apperrors.NewValidation("user image/file/audio/video 当前不接受 @ 参数")
|
||
}
|
||
if (group != "" || chatQuery != "") && (contentType == "text" || contentType == "markdown") {
|
||
if err := validateCurrentUserMentionConsistency(
|
||
messagesSendBody(rt), atOpenIDs, rt.Bool("at-all")); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
case "bot":
|
||
if chatQuery != "" || userQuery != "" {
|
||
return apperrors.NewValidation("--identity bot 当前不接受 --chat-query 或 --user-query;请传真实群 ID 或批量用户 ID")
|
||
}
|
||
if rt.Str("robot-code") == "" {
|
||
return apperrors.NewValidation("--identity bot 必须指定 --robot-code")
|
||
}
|
||
hasDirect := len(users)+len(openIDs) > 0
|
||
hasGroup := group != "" || len(botGroups) > 0
|
||
if hasGroup == hasDirect {
|
||
return apperrors.NewValidation("--identity bot 时单群/多群与批量单聊目标必须且只能指定一类")
|
||
}
|
||
if group != "" && len(botGroups) > 0 {
|
||
return apperrors.NewValidation("--identity bot 时 --group/--chat-id 与 --groups/--groups-file 不能同时使用")
|
||
}
|
||
if userID != "" || openID != "" || rt.Str("webhook-token") != "" {
|
||
return apperrors.NewValidation("--identity bot 不接受 --user、--open-dingtalk-id 或 --webhook-token")
|
||
}
|
||
if len(atMobiles) > 0 {
|
||
return apperrors.NewValidation("--identity bot 不接受 --at-mobiles")
|
||
}
|
||
if hasDirect && (len(atOpenIDs) > 0 || len(atUserIDs) > 0) {
|
||
return apperrors.NewValidation("bot 批量单聊不接受指定 @对象;@对象只适用于 bot 群聊")
|
||
}
|
||
if messagesSendIdempotencyKey(rt) != "" {
|
||
return apperrors.NewValidation("--uuid 当前仅 user 身份的下层支持")
|
||
}
|
||
if !messageIdentitySupportsContent(identity, contentType) {
|
||
return apperrors.NewValidation("--identity bot 当前下层只支持 text/markdown")
|
||
}
|
||
case "webhook":
|
||
if chatQuery != "" || userQuery != "" {
|
||
return apperrors.NewValidation("--identity webhook 的目标由 token 所在群决定,不接受 --chat-query 或 --user-query")
|
||
}
|
||
if rt.Str("webhook-token") == "" {
|
||
return apperrors.NewValidation("--identity webhook 必须指定 --webhook-token")
|
||
}
|
||
if group != "" || len(botGroups) > 0 || userID != "" || openID != "" || len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" {
|
||
return apperrors.NewValidation("--identity webhook 的目标由 token 所在群决定,不接受其他目标或 bot Code")
|
||
}
|
||
if len(atOpenIDs) > 0 {
|
||
return apperrors.NewValidation("--identity webhook 不接受 --at-open-dingtalk-ids")
|
||
}
|
||
if messagesSendIdempotencyKey(rt) != "" {
|
||
return apperrors.NewValidation("--uuid 当前仅 user 身份的下层支持")
|
||
}
|
||
if !messageIdentitySupportsContent(identity, contentType) {
|
||
return apperrors.NewValidation("--identity webhook 当前下层只支持 text/markdown")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateCurrentUserMentionConsistency(body string, atOpenIDs []string, atAll bool) error {
|
||
declared := make(map[string]struct{}, len(atOpenIDs))
|
||
for _, id := range atOpenIDs {
|
||
declared[id] = struct{}{}
|
||
}
|
||
|
||
missingPlaceholders := make([]string, 0)
|
||
for _, id := range atOpenIDs {
|
||
if !containsCurrentUserMentionToken(body, id) {
|
||
missingPlaceholders = append(missingPlaceholders, id)
|
||
}
|
||
}
|
||
if len(missingPlaceholders) > 0 {
|
||
return apperrors.NewValidation(fmt.Sprintf(
|
||
"--at-open-dingtalk-ids 中的成员必须在正文中使用对应 <@openDingTalkId> 占位符;缺少: %s",
|
||
strings.Join(missingPlaceholders, ","),
|
||
))
|
||
}
|
||
|
||
undeclared := make([]string, 0)
|
||
for _, id := range currentUserMentionBodyIDs(body) {
|
||
if _, ok := declared[id]; !ok {
|
||
undeclared = append(undeclared, id)
|
||
}
|
||
}
|
||
if len(undeclared) > 0 {
|
||
return apperrors.NewValidation(fmt.Sprintf(
|
||
"正文中的成员 <@openDingTalkId> 占位符必须同时通过 --at-open-dingtalk-ids 声明;未声明: %s",
|
||
strings.Join(undeclared, ","),
|
||
))
|
||
}
|
||
if !atAll && containsCurrentUserMentionToken(body, "all") {
|
||
return apperrors.NewValidation("正文中的 <@all> 占位符必须同时指定 --at-all")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func containsCurrentUserMentionToken(body, id string) bool {
|
||
placeholder := "@" + id
|
||
for searchFrom := 0; ; {
|
||
offset := strings.Index(body[searchFrom:], placeholder)
|
||
if offset < 0 {
|
||
return false
|
||
}
|
||
end := searchFrom + offset + len(placeholder)
|
||
if end == len(body) {
|
||
return true
|
||
}
|
||
next, _ := utf8.DecodeRuneInString(body[end:])
|
||
if !unicode.IsLetter(next) && !unicode.IsDigit(next) && next != '_' && next != '-' {
|
||
return true
|
||
}
|
||
searchFrom = end
|
||
}
|
||
}
|
||
|
||
func currentUserMentionBodyIDs(body string) []string {
|
||
ids := make([]string, 0)
|
||
for searchFrom := 0; ; {
|
||
offset := strings.IndexByte(body[searchFrom:], '@')
|
||
if offset < 0 {
|
||
break
|
||
}
|
||
start := searchFrom + offset + 1
|
||
end := start
|
||
for end < len(body) {
|
||
current := body[end]
|
||
if (current < 'a' || current > 'z') &&
|
||
(current < 'A' || current > 'Z') &&
|
||
(current < '0' || current > '9') {
|
||
break
|
||
}
|
||
end++
|
||
}
|
||
id := body[start:end]
|
||
if targetresolver.LooksLikeCurrentDOpenDingTalkID(id) {
|
||
ids = appendUniqueShortcutString(ids, id)
|
||
}
|
||
searchFrom = end
|
||
}
|
||
return ids
|
||
}
|
||
|
||
func executeMessagesSend(rt *shortcut.RuntimeContext) error {
|
||
identity := messagesSendIdentity(rt)
|
||
body := messagesSendBody(rt)
|
||
title := rt.Str("title")
|
||
if title == "" {
|
||
title = shortcutMessageTitle(body)
|
||
}
|
||
switch identity {
|
||
case "user":
|
||
contentType, err := messagesSendContentType(rt)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
group, openID, err := messagesSendUserTarget(rt)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if contentType == "image" {
|
||
content, _ := json.Marshal(map[string]string{"mediaId": rt.Str("media-id")})
|
||
params := rt.AddAIMessageTag(map[string]any{
|
||
"msgType": "image",
|
||
"content": string(content),
|
||
})
|
||
addMessagesSendUserTarget(params, group, openID)
|
||
if value := messagesSendIdempotencyKey(rt); value != "" {
|
||
params["uuid"] = value
|
||
}
|
||
return executeUnifiedMessageWrite(rt, "chat", "send_personal_message", params)
|
||
}
|
||
if contentType == "file" || contentType == "audio" || contentType == "video" {
|
||
return executeMessagesSendUserFile(rt, group, openID, contentType)
|
||
}
|
||
params := resolvedUserMarkdownParams(rt, ResolvedUserMessageTarget{
|
||
GroupID: group,
|
||
OpenDingTalkID: openID,
|
||
}, title, body, uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")), rt.Bool("at-all"), messagesSendIdempotencyKey(rt))
|
||
return executeUnifiedMessageWrite(rt, "chat", "send_personal_message", params)
|
||
case "bot":
|
||
body = helpers.NormalizeMessageMentions(
|
||
body,
|
||
append(
|
||
uniqueShortcutStrings(rt.StrSlice("at-user-ids")),
|
||
uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids"))...,
|
||
),
|
||
rt.Bool("at-all"),
|
||
false,
|
||
)
|
||
params := map[string]any{
|
||
"robotCode": rt.Str("robot-code"),
|
||
"title": title,
|
||
"markdown": body,
|
||
}
|
||
if group := rt.StrFirst("chat-id", "group"); group != "" {
|
||
params["openConversationId"] = group
|
||
if values := uniqueShortcutStrings(rt.StrSlice("at-user-ids")); len(values) > 0 {
|
||
params["atUserIds"] = values
|
||
}
|
||
if values := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")); len(values) > 0 {
|
||
params["atOpendingtalkIds"] = values
|
||
}
|
||
if rt.Bool("at-all") {
|
||
params["isAtAll"] = "true"
|
||
}
|
||
return executeUnifiedMessageWrite(rt, "bot", "send_robot_group_message", params)
|
||
}
|
||
groups, err := messagesSendBotGroups(rt)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(groups) > 0 {
|
||
items := make([]shortcutBatchWrite, 0, len(groups))
|
||
for _, group := range groups {
|
||
arguments := make(map[string]any, len(params)+1)
|
||
for key, value := range params {
|
||
arguments[key] = value
|
||
}
|
||
arguments["openConversationId"] = group
|
||
if values := uniqueShortcutStrings(rt.StrSlice("at-user-ids")); len(values) > 0 {
|
||
arguments["atUserIds"] = values
|
||
}
|
||
if values := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")); len(values) > 0 {
|
||
arguments["atOpendingtalkIds"] = values
|
||
}
|
||
if rt.Bool("at-all") {
|
||
arguments["isAtAll"] = "true"
|
||
}
|
||
items = append(items, shortcutBatchWrite{target: group, arguments: arguments})
|
||
}
|
||
return executeShortcutBatchWrite(rt, "bot", "send_robot_group_message", items)
|
||
}
|
||
if values := uniqueShortcutStrings(rt.StrSlice("users")); len(values) > 0 {
|
||
params["userIds"] = values
|
||
}
|
||
if values := uniqueShortcutStrings(rt.StrSlice("open-dingtalk-ids")); len(values) > 0 {
|
||
params["openDingtalkIds"] = values
|
||
}
|
||
if rt.Bool("at-all") {
|
||
params["isAtAll"] = "true"
|
||
}
|
||
return executeUnifiedMessageWrite(rt, "bot", "batch_send_robot_msg_to_users", params)
|
||
case "webhook":
|
||
body = helpers.NormalizeMessageMentions(
|
||
body,
|
||
append(
|
||
uniqueShortcutStrings(rt.StrSlice("at-user-ids")),
|
||
uniqueShortcutStrings(rt.StrSlice("at-mobiles"))...,
|
||
),
|
||
rt.Bool("at-all"),
|
||
false,
|
||
)
|
||
params := map[string]any{
|
||
"robotToken": rt.Str("webhook-token"),
|
||
"title": title,
|
||
"text": body,
|
||
}
|
||
if values := uniqueShortcutStrings(rt.StrSlice("at-user-ids")); len(values) > 0 {
|
||
params["atUserIds"] = values
|
||
}
|
||
if values := uniqueShortcutStrings(rt.StrSlice("at-mobiles")); len(values) > 0 {
|
||
params["atMobiles"] = values
|
||
}
|
||
if rt.Bool("at-all") {
|
||
params["isAtAll"] = true
|
||
}
|
||
return executeUnifiedMessageWrite(rt, "bot", "send_message_by_custom_robot", params)
|
||
default:
|
||
return fmt.Errorf("unsupported identity %q", identity)
|
||
}
|
||
}
|
||
|
||
func executeUnifiedMessageWrite(rt *shortcut.RuntimeContext, product, tool string, arguments map[string]any) error {
|
||
if rt.DryRun() {
|
||
return rt.Output(map[string]any{
|
||
"dry_run": true,
|
||
"executed": false,
|
||
"preview_kind": "plan",
|
||
"tool": tool,
|
||
"actionCount": 1,
|
||
"failedCount": 0,
|
||
"actions": []map[string]any{{
|
||
"identity": messagesSendIdentity(rt),
|
||
"tool": tool,
|
||
"arguments": arguments,
|
||
}},
|
||
})
|
||
}
|
||
data, err := rt.CallMCPWriteData(product, tool, arguments)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
payload := map[string]any{
|
||
"ok": true,
|
||
"identity": messagesSendIdentity(rt),
|
||
"tool": tool,
|
||
"result": data,
|
||
}
|
||
if messagesSendIdentity(rt) == "user" && tool == "send_personal_message" {
|
||
payload["sendReceipt"] = chatmsg.ProjectMessageSendReceipt(data)
|
||
}
|
||
return rt.Output(payload)
|
||
}
|
||
|
||
func messagesSendIdentity(rt *shortcut.RuntimeContext) string {
|
||
return rt.StrFirst("as", "identity")
|
||
}
|
||
|
||
func messagesSendBody(rt *shortcut.RuntimeContext) string {
|
||
return rt.StrFirst("markdown", "text")
|
||
}
|
||
|
||
func messagesSendContentType(rt *shortcut.RuntimeContext) (string, error) {
|
||
contentType := strings.ToLower(rt.Str("msg-type"))
|
||
switch {
|
||
case rt.Str("media-id") != "":
|
||
if contentType != "" && contentType != "image" {
|
||
return "", apperrors.NewValidation("--media-id 只支持 --msg-type image")
|
||
}
|
||
return "image", nil
|
||
case rt.StrFirst("file", "file-path") != "":
|
||
if contentType == "" {
|
||
return "file", nil
|
||
}
|
||
if contentType != "file" && contentType != "audio" && contentType != "video" {
|
||
return "", apperrors.NewValidation("--file 只支持 --msg-type file/audio/video")
|
||
}
|
||
return contentType, nil
|
||
default:
|
||
if contentType == "" {
|
||
if rt.Str("markdown") != "" {
|
||
return "markdown", nil
|
||
}
|
||
return "text", nil
|
||
}
|
||
if contentType != "text" && contentType != "markdown" {
|
||
return "", apperrors.NewValidation(fmt.Sprintf("--msg-type %s 需要匹配的 --media-id 或 --file", contentType))
|
||
}
|
||
return contentType, nil
|
||
}
|
||
}
|
||
|
||
func messagesSendUserTarget(rt *shortcut.RuntimeContext) (group, openID string, err error) {
|
||
group = rt.StrFirst("chat-id", "group")
|
||
openID = rt.Str("open-dingtalk-id")
|
||
if openID != "" || group != "" {
|
||
return group, openID, nil
|
||
}
|
||
if query := rt.Str("chat-query"); query != "" {
|
||
resolved, resolveErr := targetresolver.ResolveChat(rt, query)
|
||
if resolveErr != nil {
|
||
return "", "", resolveErr
|
||
}
|
||
return resolved.Selected.OpenConversationID, "", nil
|
||
}
|
||
if query := rt.Str("user-query"); query != "" {
|
||
resolved, resolveErr := targetresolver.ResolveUser(rt, query, targetresolver.IdentityOpenDingTalkID)
|
||
if resolveErr != nil {
|
||
return "", "", resolveErr
|
||
}
|
||
return "", resolved.Selected.OpenDingTalkID, nil
|
||
}
|
||
openID, err = resolveUserOpenDingTalkID(rt, rt.Str("user"))
|
||
if err != nil {
|
||
return "", "", err
|
||
}
|
||
return "", openID, nil
|
||
}
|
||
|
||
// ResolvedUserMessageTarget is the stable target accepted by the shared user
|
||
// send engine after natural-name resolution has completed.
|
||
type ResolvedUserMessageTarget struct {
|
||
GroupID string
|
||
OpenDingTalkID string
|
||
}
|
||
|
||
// ExecuteResolvedUserMarkdown lets narrow semantic shortcuts such as +dm and
|
||
// +send-to-group reuse the same target/content/AI-tag parameter builder while
|
||
// preserving their existing raw lower-response output contract.
|
||
func ExecuteResolvedUserMarkdown(
|
||
rt *shortcut.RuntimeContext,
|
||
target ResolvedUserMessageTarget,
|
||
text string,
|
||
) error {
|
||
params := resolvedUserMarkdownParams(
|
||
rt,
|
||
target,
|
||
text,
|
||
text,
|
||
nil,
|
||
false,
|
||
"",
|
||
)
|
||
return rt.CallMCP("send_personal_message", params)
|
||
}
|
||
|
||
func resolvedUserMarkdownParams(
|
||
rt *shortcut.RuntimeContext,
|
||
target ResolvedUserMessageTarget,
|
||
title, body string,
|
||
atOpenIDs []string,
|
||
atAll bool,
|
||
idempotencyKey string,
|
||
) map[string]any {
|
||
if target.GroupID != "" {
|
||
body = helpers.NormalizeMessageMentions(body, atOpenIDs, atAll, true)
|
||
}
|
||
content, _ := jsonutil.Marshal(map[string]string{"title": title, "text": body})
|
||
params := rt.AddAIMessageTag(map[string]any{
|
||
"msgType": "markdown",
|
||
"content": string(content),
|
||
})
|
||
addMessagesSendUserTarget(params, target.GroupID, target.OpenDingTalkID)
|
||
if target.GroupID != "" {
|
||
if len(atOpenIDs) > 0 {
|
||
params["atOpenDingTalkIds"] = atOpenIDs
|
||
}
|
||
if atAll {
|
||
params["atAll"] = true
|
||
}
|
||
}
|
||
if idempotencyKey != "" {
|
||
params["uuid"] = idempotencyKey
|
||
}
|
||
return params
|
||
}
|
||
|
||
func resolveUserOpenDingTalkID(rt *shortcut.RuntimeContext, userID string) (string, error) {
|
||
userID = strings.TrimSpace(userID)
|
||
data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{
|
||
"keyword": userID,
|
||
})
|
||
if err != nil {
|
||
return "", fmt.Errorf("通过通讯录搜索把 userId %q 解析为 openDingTalkId 失败: %w", userID, err)
|
||
}
|
||
|
||
exactRows := 0
|
||
openIDs := make([]string, 0, 1)
|
||
for _, user := range shortcutMapSlice(data["result"]) {
|
||
if shortcutString(user, "userId", "userID") != userID {
|
||
continue
|
||
}
|
||
exactRows++
|
||
if openID := shortcutString(user, "openDingTalkId", "openDingtalkId"); openID != "" {
|
||
openIDs = appendUniqueShortcutString(openIDs, openID)
|
||
}
|
||
}
|
||
switch {
|
||
case len(openIDs) == 1:
|
||
return openIDs[0], nil
|
||
case len(openIDs) > 1:
|
||
return "", apperrors.NewValidation(fmt.Sprintf(
|
||
"通讯录中 userId %q 精确匹配到多个不同的 openDingTalkId,无法安全选择",
|
||
userID,
|
||
))
|
||
case exactRows > 0:
|
||
return "", apperrors.NewValidation(fmt.Sprintf(
|
||
"通讯录已精确匹配 userId %q,但结果未返回 openDingTalkId",
|
||
userID,
|
||
))
|
||
default:
|
||
return "", apperrors.NewValidation(fmt.Sprintf(
|
||
"通讯录搜索结果中没有精确匹配的 userId %q",
|
||
userID,
|
||
))
|
||
}
|
||
}
|
||
|
||
func executeMessagesSendUserFile(
|
||
rt *shortcut.RuntimeContext,
|
||
group, openID, requestedType string,
|
||
) error {
|
||
rawPath := rt.StrFirst("file", "file-path")
|
||
safePath, err := apperrors.SafeInputPath(rawPath)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
meta, err := helpers.BuildConversationLocalFileMeta(safePath, "", "")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
uploadTargetArgs := map[string]any{}
|
||
addMessagesSendUserUploadTarget(uploadTargetArgs, group, openID)
|
||
sendTargetArgs := map[string]any{}
|
||
addMessagesSendUserTarget(sendTargetArgs, group, openID)
|
||
idempotencyKey := messagesSendIdempotencyKey(rt)
|
||
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{
|
||
{
|
||
"identity": "user",
|
||
"tool": "init/commit_conversation_file_upload",
|
||
"target": uploadTargetArgs,
|
||
"file": map[string]any{
|
||
"path": rawPath,
|
||
"name": meta.FileName,
|
||
"sizeBytes": meta.FileSize,
|
||
},
|
||
},
|
||
{
|
||
"identity": "user",
|
||
"tool": "send_personal_message",
|
||
"requestedMessageType": requestedType,
|
||
"effectiveMessageType": "file",
|
||
"target": sendTargetArgs,
|
||
},
|
||
},
|
||
})
|
||
}
|
||
uploadContext, cancelUpload := context.WithTimeout(
|
||
rt.Command().Context(), messagesSendFileUploadTimeout)
|
||
defer cancelUpload()
|
||
commitText, err := helpers.UploadConversationLocalFile(
|
||
uploadContext, uploadTargetArgs, meta, idempotencyKey)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
dentryID, spaceID, err := helpers.ParseConversationFileSendIDs(commitText)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
content, _ := helpers.BuildConversationFileContent(dentryID, spaceID, meta)
|
||
params := rt.AddAIMessageTag(map[string]any{
|
||
"msgType": "file",
|
||
"content": content,
|
||
})
|
||
addMessagesSendUserTarget(params, group, openID)
|
||
if idempotencyKey != "" {
|
||
params["uuid"] = idempotencyKey
|
||
}
|
||
data, err := rt.CallMCPWriteData("chat", "send_personal_message", params)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
payload := map[string]any{
|
||
"ok": true,
|
||
"identity": "user",
|
||
"tool": "send_personal_message",
|
||
"requestedMessageType": requestedType,
|
||
"effectiveMessageType": "file",
|
||
"file": map[string]any{
|
||
"path": rawPath,
|
||
"name": meta.FileName,
|
||
"sizeBytes": meta.FileSize,
|
||
},
|
||
"result": data,
|
||
}
|
||
payload["sendReceipt"] = chatmsg.ProjectMessageSendReceipt(data)
|
||
return rt.Output(payload)
|
||
}
|
||
|
||
func addMessagesSendUserTarget(params map[string]any, group, openID string) {
|
||
if group != "" {
|
||
params["openConversationId"] = group
|
||
return
|
||
}
|
||
params["receiverOpenDingTalkId"] = openID
|
||
}
|
||
|
||
func addMessagesSendUserUploadTarget(params map[string]any, group, openID string) {
|
||
if group != "" {
|
||
params["openConversationId"] = group
|
||
return
|
||
}
|
||
params["openDingTalkId"] = openID
|
||
}
|
||
|
||
func nonEmptyStringCount(values ...string) int {
|
||
count := 0
|
||
for _, value := range values {
|
||
if strings.TrimSpace(value) != "" {
|
||
count++
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
func messagesSendIdempotencyKey(rt *shortcut.RuntimeContext) string {
|
||
return rt.StrFirst("idempotency-key", "uuid")
|
||
}
|
||
|
||
func messagesSendBotGroups(rt *shortcut.RuntimeContext) ([]string, error) {
|
||
if rt.Changed("groups") && rt.Changed("groups-file") {
|
||
return nil, apperrors.NewValidation("--groups 与 --groups-file 不能同时指定")
|
||
}
|
||
groups := uniqueShortcutStrings(rt.StrSlice("groups"))
|
||
if path := rt.Str("groups-file"); path != "" {
|
||
safePath, err := apperrors.SafeInputPath(path)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("校验 --groups-file 失败: %w", err)
|
||
}
|
||
info, err := os.Stat(safePath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取 --groups-file 失败: %w", err)
|
||
}
|
||
if !info.Mode().IsRegular() {
|
||
return nil, apperrors.NewValidation("--groups-file 必须是普通文本文件")
|
||
}
|
||
if info.Size() > messagesSendMaxGroupFileSize {
|
||
return nil, apperrors.NewValidation("--groups-file 不能超过 1 MiB")
|
||
}
|
||
raw, err := messagesSendReadGroupFile(safePath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取 --groups-file 失败: %w", err)
|
||
}
|
||
values := make([]string, 0)
|
||
for _, line := range strings.Split(string(raw), "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if line == "" || strings.HasPrefix(line, "#") {
|
||
continue
|
||
}
|
||
values = append(values, strings.Split(line, ",")...)
|
||
}
|
||
groups = uniqueShortcutStrings(values)
|
||
}
|
||
if len(groups) > messagesSendMaxBotGroups {
|
||
return nil, apperrors.NewValidation(fmt.Sprintf(
|
||
"bot 多群发送最多支持 %d 个群,当前 %d 个",
|
||
messagesSendMaxBotGroups, len(groups),
|
||
))
|
||
}
|
||
if (rt.Changed("groups") || rt.Changed("groups-file")) && len(groups) == 0 {
|
||
return nil, apperrors.NewValidation("bot 多群发送至少需要一个 openConversationId")
|
||
}
|
||
return groups, nil
|
||
}
|
||
|
||
func shortcutMessageTitle(text string) string {
|
||
text = strings.TrimSpace(strings.SplitN(text, "\n", 2)[0])
|
||
if utf8.RuneCountInString(text) <= 40 {
|
||
return text
|
||
}
|
||
runes := []rune(text)
|
||
return string(runes[:40])
|
||
}
|
||
|
||
func init() {
|
||
shortcut.Register(withReviewedChatShortcutContracts(MessagesSend)...)
|
||
}
|