278 lines
10 KiB
Go
278 lines
10 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 (
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||
)
|
||
|
||
// Approve: agree to ONE of MY pending OA approvals by matching a keyword in its
|
||
// title / order number, in a single command (钉钉原生编排能力).
|
||
//
|
||
// Steps (all tool names + params copied verbatim from helpers/oa.go):
|
||
//
|
||
// 1. list my pending approvals via list_pending_approvals (starTime / endTime
|
||
// as float64 millis over a recent window, plus query=keyword for
|
||
// server-side narrowing — mirroring `dws oa approval list-pending`);
|
||
//
|
||
// 2. scan the returned instances and keep the ones whose title / order number
|
||
// contains --keyword: none → "没找到待审批单据", many → list candidates
|
||
// (标题 + processInstanceId) so the caller can be more specific, exactly one
|
||
// → take its processInstanceId;
|
||
//
|
||
// 3. resolve the pending taskId for that instance via list_pending_tasks
|
||
// (processInstanceId), then agree via approve_processInstance
|
||
// (processInstanceId + taskId as float64, optional remark) —
|
||
// mirroring `dws oa approval tasks` + `dws oa approval approve`.
|
||
//
|
||
// dws oa +approve-by --keyword 报销
|
||
// dws oa +approve-by --keyword 出差单 --comment "同意"
|
||
var Approve = shortcut.Shortcut{
|
||
Service: "oa",
|
||
Command: "+approve-by",
|
||
Product: "oa",
|
||
Description: "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)",
|
||
Intent: "当你只记得某张待你审批单据的标题或单号关键词、想直接把它审批通过,却不想先翻待办列表、" +
|
||
"复制审批实例 ID 再查任务 ID 时使用;内部先拉取你近三个月待处理的审批单,按标题/单号包含关键词匹配:" +
|
||
"没匹配到会提示「没找到待审批单据」,匹配到多条会列出候选(标题+实例ID)让你写得更精确," +
|
||
"唯一命中时先取出它的待审批任务 ID,再以「同意」提交。这会真实提交审批决策且不可撤回,请确认关键词足够精确。",
|
||
Risk: shortcut.RiskWrite,
|
||
Flags: []shortcut.Flag{
|
||
{Name: "keyword", Type: shortcut.FlagString, Desc: "待审批单据的单号或标题关键词", Required: true},
|
||
{Name: "comment", Type: shortcut.FlagString, Desc: "审批意见(可选)", Required: false},
|
||
},
|
||
Tips: []string{
|
||
`dws oa +approve-by --keyword 报销`,
|
||
`dws oa +approve-by --keyword 出差单 --comment "同意"`,
|
||
},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
keyword := strings.TrimSpace(rt.Str("keyword"))
|
||
if keyword == "" {
|
||
return apperrors.NewValidation("请用 --keyword 提供待审批单据的单号或标题关键词")
|
||
}
|
||
|
||
// Step 1 — list my pending approvals. starTime/endTime are float64
|
||
// milliseconds and query narrows server-side, mirroring
|
||
// helpers.list-pending (list_pending_approvals). Window: last ~90 days.
|
||
now := time.Now()
|
||
listArgs := map[string]any{
|
||
"starTime": float64(now.AddDate(0, 0, -90).UnixMilli()),
|
||
"endTime": float64(now.UnixMilli()),
|
||
"query": keyword,
|
||
}
|
||
data, err := rt.CallMCPData("oa", "list_pending_approvals", listArgs)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Step 2 — match by title / order number substring.
|
||
matches := shortcutApproveMatch(data, keyword)
|
||
switch {
|
||
case len(matches) == 0:
|
||
return apperrors.NewValidation(fmt.Sprintf(
|
||
"没找到待审批单据:待我处理的审批里没有标题/单号包含 %q 的单据。", keyword))
|
||
case len(matches) > 1:
|
||
return apperrors.NewValidation(fmt.Sprintf(
|
||
"%q 匹配到 %d 条待审批单据,请用更精确的关键词,或用 `dws oa approval approve --instance-id --task-id` 指定:%s",
|
||
keyword, len(matches), strings.Join(shortcutApproveLabels(matches), ";")))
|
||
}
|
||
|
||
instanceID := matches[0].instanceID
|
||
|
||
// Step 3a — resolve the pending taskId for this instance via
|
||
// list_pending_tasks (processInstanceId), mirroring `dws oa approval tasks`.
|
||
tasksData, err := rt.CallMCPData("oa", "list_pending_tasks", map[string]any{
|
||
"processInstanceId": instanceID,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
taskID := shortcutApproveTaskID(tasksData)
|
||
if taskID == "" {
|
||
return apperrors.NewValidation(fmt.Sprintf(
|
||
"没能拿到 %q 的待审批任务 ID,请用 `dws oa approval tasks --instance-id %s` 查看后手动 approve。",
|
||
matches[0].title, instanceID))
|
||
}
|
||
|
||
// Step 3b — agree. taskId is passed as float64 and remark is optional,
|
||
// mirroring helpers.approve (approve_processInstance).
|
||
taskIDNum, err := strconv.ParseFloat(taskID, 64)
|
||
if err != nil {
|
||
return apperrors.NewValidation(fmt.Sprintf("任务 ID %q 不是合法数字,无法审批。", taskID))
|
||
}
|
||
approveArgs := map[string]any{
|
||
"processInstanceId": instanceID,
|
||
"taskId": taskIDNum,
|
||
}
|
||
if c := strings.TrimSpace(rt.Str("comment")); c != "" {
|
||
approveArgs["remark"] = c
|
||
}
|
||
return rt.CallMCP("approve_processInstance", approveArgs)
|
||
},
|
||
}
|
||
|
||
// shortcutApproveInstance is the minimal identity we need from a pending-approval
|
||
// entry: its processInstanceId (fed to approve/tasks) and a human title/order
|
||
// number for matching + disambiguation.
|
||
type shortcutApproveInstance struct {
|
||
instanceID string
|
||
title string
|
||
}
|
||
|
||
// shortcutApproveMatch walks a list_pending_approvals response and returns the
|
||
// instances whose title / order number contains keyword (case-insensitive). The
|
||
// gateway wraps the list under one of several common container keys, so we probe
|
||
// them defensively (mirroring latestMinutesItems / shortcutTodoCards).
|
||
func shortcutApproveMatch(data map[string]any, keyword string) []shortcutApproveInstance {
|
||
items := shortcutApproveItems(data)
|
||
kw := strings.ToLower(keyword)
|
||
var out []shortcutApproveInstance
|
||
seen := map[string]bool{}
|
||
for _, m := range items {
|
||
id := shortcutApproveInstanceID(m)
|
||
if id == "" || seen[id] {
|
||
continue
|
||
}
|
||
title := shortcutApproveTitle(m)
|
||
// Match on either the visible title/order number or the instance id.
|
||
if !strings.Contains(strings.ToLower(title), kw) &&
|
||
!strings.Contains(strings.ToLower(id), kw) {
|
||
continue
|
||
}
|
||
seen[id] = true
|
||
if title == "" {
|
||
title = id
|
||
}
|
||
out = append(out, shortcutApproveInstance{instanceID: id, title: title})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// shortcutApproveItems pulls the list of pending-approval entries out of the
|
||
// response, probing common container shapes before scanning for a bare list.
|
||
func shortcutApproveItems(data map[string]any) []map[string]any {
|
||
for _, key := range []string{"result", "list", "instances", "items", "data", "records", "processList"} {
|
||
if arr, ok := data[key].([]any); ok {
|
||
return shortcutApproveToMaps(arr)
|
||
}
|
||
if inner, ok := data[key].(map[string]any); ok {
|
||
for _, k2 := range []string{"list", "instances", "items", "records", "result", "processList"} {
|
||
if arr, ok := inner[k2].([]any); ok {
|
||
return shortcutApproveToMaps(arr)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func shortcutApproveToMaps(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
|
||
}
|
||
|
||
// shortcutApproveInstanceID reads an entry's processInstanceId (the value the
|
||
// helper feeds to --instance-id), tolerating a few common field names and both
|
||
// string / numeric JSON encodings.
|
||
func shortcutApproveInstanceID(m map[string]any) string {
|
||
for _, key := range []string{"processInstanceId", "processInstanceID", "instanceId", "businessId"} {
|
||
if s := shortcutApproveStr(m[key]); s != "" {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// shortcutApproveTitle reads a human-facing title / order number for matching
|
||
// and disambiguation, probing common field names.
|
||
func shortcutApproveTitle(m map[string]any) string {
|
||
for _, key := range []string{"title", "processTitle", "name", "subject", "businessId", "processCode"} {
|
||
if s := shortcutApproveStr(m[key]); s != "" {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// shortcutApproveTaskID walks a list_pending_tasks response and returns the
|
||
// first pending taskId (the value the helper feeds to --task-id).
|
||
func shortcutApproveTaskID(data map[string]any) string {
|
||
items := shortcutApproveItems(data)
|
||
// list_pending_tasks may also return a bare {"result": [...]}/task list; the
|
||
// probe above covers those. Some gateways return a single task object.
|
||
if len(items) == 0 {
|
||
if id := shortcutApproveTaskIDField(data); id != "" {
|
||
return id
|
||
}
|
||
if r, ok := data["result"].(map[string]any); ok {
|
||
if id := shortcutApproveTaskIDField(r); id != "" {
|
||
return id
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
for _, m := range items {
|
||
if id := shortcutApproveTaskIDField(m); id != "" {
|
||
return id
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func shortcutApproveTaskIDField(m map[string]any) string {
|
||
for _, key := range []string{"taskId", "taskID", "activityId"} {
|
||
if s := shortcutApproveStr(m[key]); s != "" {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// shortcutApproveStr coerces a JSON value into a string, tolerating string and
|
||
// numeric encodings (taskId / instance id can arrive as either).
|
||
func shortcutApproveStr(v any) string {
|
||
switch t := v.(type) {
|
||
case string:
|
||
return t
|
||
case float64:
|
||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||
case int64:
|
||
return strconv.FormatInt(t, 10)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func shortcutApproveLabels(items []shortcutApproveInstance) []string {
|
||
out := make([]string, 0, len(items))
|
||
for _, c := range items {
|
||
out = append(out, fmt.Sprintf("%s(instanceId=%s)", c.title, c.instanceID))
|
||
}
|
||
return out
|
||
}
|
||
|
||
func init() {
|
||
shortcut.Register(Approve)
|
||
}
|