mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Task commands now return structured, typed errors instead of the legacy exit-code envelope: every failure carries a stable category, subtype, and recovery hint, so callers can branch on the error class instead of parsing messages. Exit codes derive from the error category — input validation exits 2, a permission denial exits 3, other API errors exit 1. Batch operations (adding tasks to a tasklist, creating a tasklist with tasks) now report partial failure honestly: the per-item successes and failures stay on stdout and the command exits non-zero instead of masking failures as a success.
192 lines
6.6 KiB
Go
192 lines
6.6 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package task
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/errclass"
|
|
"github.com/larksuite/cli/internal/util"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
var relativeTimeRe = regexp.MustCompile(`^([+-])(\d+)([dwmh])$`)
|
|
|
|
func isRelativeTime(s string) bool {
|
|
return relativeTimeRe.MatchString(s)
|
|
}
|
|
|
|
func parseRelativeTime(s string) (time.Time, error) {
|
|
matches := relativeTimeRe.FindStringSubmatch(s)
|
|
if len(matches) == 0 {
|
|
return time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid relative time format: %s", s)
|
|
}
|
|
|
|
sign := matches[1]
|
|
amountStr := matches[2]
|
|
unit := matches[3]
|
|
|
|
amount, err := strconv.Atoi(amountStr)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
|
|
if sign == "-" {
|
|
amount = -amount
|
|
}
|
|
|
|
now := time.Now()
|
|
switch unit {
|
|
case "d":
|
|
return now.AddDate(0, 0, amount), nil
|
|
case "w":
|
|
return now.AddDate(0, 0, amount*7), nil
|
|
case "m":
|
|
return now.Add(time.Duration(amount) * time.Minute), nil
|
|
case "h":
|
|
return now.Add(time.Duration(amount) * time.Hour), nil
|
|
}
|
|
panic(fmt.Sprintf("unreachable: relativeTimeRe matched unexpected unit %q", unit))
|
|
}
|
|
|
|
const (
|
|
// ErrCodeTaskInvalidParams is returned when request parameters are invalid.
|
|
ErrCodeTaskInvalidParams = 1470400
|
|
// ErrCodeTaskPermissionDenied is returned when the user has no permission.
|
|
ErrCodeTaskPermissionDenied = 1470403
|
|
// ErrCodeTaskNotFound is returned when the resource is not found.
|
|
ErrCodeTaskNotFound = 1470404
|
|
// ErrCodeTaskConflict is returned when concurrent call conflict.
|
|
ErrCodeTaskConflict = 1470422
|
|
// ErrCodeTaskInternalError is returned when server error occurs.
|
|
ErrCodeTaskInternalError = 1470500
|
|
// ErrCodeTaskAssigneeLimit is returned when assignee limit exceeded.
|
|
ErrCodeTaskAssigneeLimit = 1470610
|
|
// ErrCodeTaskFollowerLimit is returned when follower limit exceeded.
|
|
ErrCodeTaskFollowerLimit = 1470611
|
|
// ErrCodeTasklistMemberLimit is returned when tasklist member limit exceeded.
|
|
ErrCodeTasklistMemberLimit = 1470612
|
|
// ErrCodeTaskReminderExists is returned when reminder already exists.
|
|
ErrCodeTaskReminderExists = 1470613
|
|
)
|
|
|
|
// taskAPIHints carries the task-specific recovery hint for each known Lark API
|
|
// code, layered onto the typed error after errclass.BuildAPIError classifies
|
|
// it. errclass.APIHint only covers context-free subtypes (e.g. conflict); these
|
|
// hints carry the resource context APIHint intentionally leaves to the caller.
|
|
// Authorization (1470403) is omitted: BuildAPIError already attaches the
|
|
// canonical permission hint.
|
|
var taskAPIHints = map[int]string{
|
|
ErrCodeTaskInvalidParams: "Please check required fields, field lengths, or parameter logic (e.g., reminders require a due date).",
|
|
ErrCodeTaskNotFound: "Please verify if the task, tasklist, or group ID is correct and has not been deleted.",
|
|
ErrCodeTaskConflict: "Avoid making concurrent API calls using the same client_token.",
|
|
ErrCodeTaskInternalError: "Please try again. If the error persists, check the content validity or contact support.",
|
|
ErrCodeTaskAssigneeLimit: "The current task has reached the maximum number of assignees.",
|
|
ErrCodeTaskFollowerLimit: "The current task has reached the maximum number of followers.",
|
|
ErrCodeTasklistMemberLimit: "The current tasklist has reached the maximum number of members.",
|
|
ErrCodeTaskReminderExists: "The task already has a reminder set. Remove the existing reminder before adding a new one.",
|
|
}
|
|
|
|
func callTaskAPITyped(runtime *common.RuntimeContext, method, url string, params map[string]interface{}, body interface{}) (map[string]interface{}, error) {
|
|
data, err := runtime.CallAPITyped(method, url, params, body)
|
|
return data, applyTaskAPIHint(err)
|
|
}
|
|
|
|
func applyTaskAPIHint(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if p, ok := errs.ProblemOf(err); ok {
|
|
if hint := taskAPIHints[p.Code]; hint != "" {
|
|
p.Hint = hint
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
// HandleTaskApiResult interprets a parsed Lark API response. A non-zero code is
|
|
// classified into a typed errs.* error by errclass.BuildAPIError — Category,
|
|
// Subtype, Code, and log_id are sourced from internal/errclass/codemeta_task.go
|
|
// — with the task-specific recovery hint (taskAPIHints) layered on top.
|
|
func HandleTaskApiResult(result interface{}, err error, action string) (map[string]interface{}, error) {
|
|
return handleTaskAPIResult(result, err, action, errclass.ClassifyContext{})
|
|
}
|
|
|
|
func HandleTaskApiResultWithContext(result interface{}, err error, action string, cc errclass.ClassifyContext) (map[string]interface{}, error) {
|
|
return handleTaskAPIResult(result, err, action, cc)
|
|
}
|
|
|
|
func handleTaskAPIResult(result interface{}, err error, action string, cc errclass.ClassifyContext) (map[string]interface{}, error) {
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resultMap, _ := result.(map[string]interface{})
|
|
codeVal, hasCode := resultMap["code"]
|
|
if !hasCode {
|
|
// A Lark response always carries a top-level code; its absence (with no
|
|
// transport error) means a malformed or unexpected body.
|
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: unexpected response (missing code field)", action)
|
|
}
|
|
|
|
code, ok := util.ToFloat64(codeVal)
|
|
if !ok {
|
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: malformed response (non-numeric code %v)", action, codeVal)
|
|
}
|
|
larkCode := int(code)
|
|
if larkCode != 0 {
|
|
typedErr := errclass.BuildAPIError(resultMap, cc)
|
|
return nil, applyTaskAPIHint(typedErr)
|
|
}
|
|
|
|
data, _ := resultMap["data"].(map[string]interface{})
|
|
return data, nil
|
|
}
|
|
|
|
func contains(slice []string, item string) bool {
|
|
for _, s := range slice {
|
|
if s == item {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// truncateTaskURL removes extra query parameters from task applink, keeping only guid.
|
|
func truncateTaskURL(u string) string {
|
|
if u == "" {
|
|
return ""
|
|
}
|
|
if idx := strings.Index(u, "&"); idx != -1 {
|
|
return u[:idx]
|
|
}
|
|
return u
|
|
}
|
|
|
|
// parseTimeFlagSec parses a time flag that can be absolute (ISO 8601, timestamp) or relative (+/- Nd/w/m/h).
|
|
// It returns the Unix seconds string.
|
|
func parseTimeFlagSec(input string, hint string) (string, error) {
|
|
if isRelativeTime(input) {
|
|
t, err := parseRelativeTime(input)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// Snap to day if unit is days or weeks
|
|
if strings.HasSuffix(input, "d") || strings.HasSuffix(input, "w") {
|
|
if hint == "end" {
|
|
t = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, t.Location())
|
|
} else {
|
|
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
|
}
|
|
}
|
|
return fmt.Sprintf("%d", t.Unix()), nil
|
|
}
|
|
return common.ParseTime(input, hint)
|
|
}
|