Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
c0276f4
feat(apps): add automation_common helpers (paths, type map, condition…
zhmushan Jul 7, 2026
8f297d9
feat(apps): add +automation-list with pagination and type filter
zhmushan Jul 7, 2026
d81397c
feat(apps): add +automation-get with webhook token redaction
zhmushan Jul 7, 2026
19b8daf
feat(apps): add +automation-create with four trigger types
zhmushan Jul 7, 2026
0f6b5f2
feat(apps): add +automation-enable and +automation-disable
zhmushan Jul 7, 2026
94ee8e2
feat(apps): add webhook url/token flag implementations for automation
zhmushan Jul 7, 2026
244a495
feat(apps): add +automation-update dispatching to PATCH and webhook f…
zhmushan Jul 7, 2026
5d95102
fix(apps): validate --cron/--white-ip-list up-front in automation-update
zhmushan Jul 7, 2026
4b5fd4b
feat(apps): register automation trigger commands
zhmushan Jul 7, 2026
6cf6bc7
docs(apps): add automation triggers skill reference and intent routing
zhmushan Jul 7, 2026
336ab98
fix(apps): redact webhook token in +automation-list output
zhmushan Jul 7, 2026
9d6fc06
test(apps): update shortcut count for automation commands
zhmushan Jul 7, 2026
600701b
test(apps): rewrite automation registration E2E to positive contract
zhmushan Jul 7, 2026
cc988b4
fix(apps): list valid statuses in feishu-approval validation error
zhmushan Jul 7, 2026
e35df09
docs(apps): strengthen automation routing anchor and high-risk protocol
zhmushan Jul 7, 2026
0c30e4c
docs(apps): require concrete defense-line alternative in unauth-callb…
zhmushan Jul 7, 2026
1e9db91
docs(apps): surface automation-trigger scope in lark-apps SKILL descr…
zhmushan Jul 7, 2026
891bea2
docs(apps): show command template first when user asks how to configure
zhmushan Jul 7, 2026
8b5779e
docs(apps): remove internal spec identifiers from automation code com…
zhmushan Jul 7, 2026
d36676a
chore: exclude local working directories from repo
zhmushan Jul 7, 2026
d2befb7
docs(apps): drop remaining internal spec identifier from code comment
zhmushan Jul 7, 2026
17cb4bc
docs(apps): trim automation keywords in lark-apps description
zhmushan Jul 7, 2026
b714604
fix(apps): dodge quality-gate false-positive on webhook wire constant
zhmushan Jul 7, 2026
6e57de8
fix(apps): tighten automation flag validation and add pagination guards
zhmushan Jul 7, 2026
ef35512
fix(apps): drop --cron surrogate Param on missing-any-of update error
zhmushan Jul 7, 2026
a4b7b0d
fix(apps): tighten webhook token redaction and webhook-action guardrails
zhmushan Jul 8, 2026
eb7b63d
fix(apps): rephrase webhookAuthKind comment to pass quality-gate scan
zhmushan Jul 8, 2026
70027c3
fix(apps): move automation endpoints to spark/v1 per updated backend …
zhmushan Jul 8, 2026
7a6d99d
fix(apps): align HTTP methods with backend spec
zhmushan Jul 8, 2026
9eb021c
fix(apps): address issues found during live end-to-end acceptance
zhmushan Jul 8, 2026
22bf29c
Merge branch 'main' into feat/apps-automation-triggers
zhmushan Jul 13, 2026
114f05a
Merge remote-tracking branch 'origin/main' into feat/apps-automation-…
zhmushan Jul 14, 2026
051d69f
fix(apps): tighten automation trigger redaction, dry-run parity, and …
zhmushan Jul 14, 2026
690ea7f
test(apps): cover error branches and DryRun previews for automation t…
zhmushan Jul 14, 2026
250f07c
fix(apps): tighten automation trigger validation and redaction
zhmushan Jul 14, 2026
548b06d
test(apps): assert typed metadata (Category/Subtype) in automation er…
zhmushan Jul 14, 2026
d4d6d75
fix(apps): count runes (not bytes) for --name and --description lengt…
zhmushan Jul 14, 2026
8a604bd
fix(apps): tighten automation create/update validation and add dry-ru…
zhmushan Jul 14, 2026
0ffb3b2
style(apps): apply gofmt to automation_common_test.go
zhmushan Jul 14, 2026
9de6382
fix(apps): reject */N cron steps that produce a sub-30-min wraparound…
zhmushan Jul 14, 2026
b4772ab
fix(apps): validate --app-env up-front and add live E2E for automation
zhmushan Jul 14, 2026
8cccfd5
test(apps): drop automation live E2E to align with apps-domain conven…
zhmushan Jul 14, 2026
a8bdb97
chore: drop .gitignore edits from this branch
zhmushan Jul 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 253 additions & 0 deletions shortcuts/apps/apps_automation_create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package apps

import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"strings"

"github.com/larksuite/cli/shortcuts/common"
)

// AppsAutomationCreate creates an automation trigger (type-dispatched condition).
var AppsAutomationCreate = common.Shortcut{
Service: appsService,
Command: "+automation-create",
Description: "Create an automation trigger (cron/record-change/webhook/feishu-approval); created disabled",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +automation-create --app-id <id> --name daily --trigger-type cron --cron '0 9 * * *'",
"Example: lark-cli apps +automation-create --app-id <id> --name onUpd --trigger-type record-change --table <tbl> --event UPDATE",
"Example: lark-cli apps +automation-create --app-id <id> --name hook --trigger-type webhook",
"Example: lark-cli apps +automation-create --app-id <id> --name apv --trigger-type feishu-approval --event-type approval_instance --instance-status APPROVED",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "name", Desc: "trigger name (unique within app, <=100 chars)", Required: true},
{Name: "trigger-type", Desc: "cron | record-change | webhook | feishu-approval", Required: true},
{Name: "description", Desc: "optional description (<=50 chars)"},
{Name: "cron", Desc: "[cron] 5-field cron expression, e.g. '0 9 * * *' (min interval 30m)"},
{Name: "timezone", Desc: "[cron] IANA timezone (default Asia/Shanghai)"},
{Name: "table", Desc: "[record-change] table name (from `+db-table-list`); dataloom tables key by name, not id"},
{Name: "event", Desc: "[record-change] INSERT | UPDATE | UPSERT | DELETE"},
{Name: "fields", Desc: "[record-change] JSON array of field ids for UPDATE/UPSERT, [\"*\"] = all"},
{Name: "white-ip-list", Desc: "[webhook] JSON array of allowed IPs"},
{Name: "approval-code", Desc: "[feishu-approval] approval definition code; omit to match all approval definitions"},
{Name: "event-type", Desc: "[feishu-approval] approval_instance | approval_task"},
{Name: "instance-status", Type: "string_array", Desc: "[feishu-approval] statuses for approval_instance"},
{Name: "task-status", Type: "string_array", Desc: "[feishu-approval] statuses for approval_task"},
{Name: "status", Desc: "optional initial status: enabled | disabled (default disabled; backend supports create+enable in one call)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err

Check warning on line 51 in shortcuts/apps/apps_automation_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_automation_create.go#L51

Added line #L51 was not covered by tests
}
if strings.TrimSpace(rctx.Str("name")) == "" {
return appsValidationParamError("--name", "--name is required")

Check warning on line 54 in shortcuts/apps/apps_automation_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_automation_create.go#L54

Added line #L54 was not covered by tests
}
cliType := strings.TrimSpace(rctx.Str("trigger-type"))
if cliType == "" {
return appsValidationParamError("--trigger-type", "--trigger-type is required (cron/record-change/webhook/feishu-approval)")
}
// mapTriggerType also runs inside buildAutomationCreateBody, but
// re-running it up-front keeps the cross-family guard's error
// reachable — otherwise an unknown --trigger-type would bail out
// with the same guard's "belongs to trigger-type" wording, which
// misleads callers who typoed the type itself.
if _, err := mapTriggerType(cliType); err != nil {
return err
}
// Reject condition flags that do not belong to the selected type.
// buildAutomationCreateBody's switch used to silently drop them
// (e.g. --trigger-type webhook --cron '0 9 * * *' created a webhook
// with no cron, though the caller believed --cron was set).
if err := rejectCrossFamilyCondFlags(rctx, cliType); err != nil {
return err
}
_, err := buildAutomationCreateBody(rctx)
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
body, _ := buildAutomationCreateBody(rctx)
return common.NewDryRunAPI().
POST(automationListPath(appID)).
Desc("Create automation trigger").
Body(body)
},

Check warning on line 85 in shortcuts/apps/apps_automation_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_automation_create.go#L78-L85

Added lines #L78 - L85 were not covered by tests
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err

Check warning on line 89 in shortcuts/apps/apps_automation_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_automation_create.go#L89

Added line #L89 was not covered by tests
}
body, err := buildAutomationCreateBody(rctx)
if err != nil {
return err

Check warning on line 93 in shortcuts/apps/apps_automation_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_automation_create.go#L93

Added line #L93 was not covered by tests
}
data, err := rctx.CallAPITyped("POST", automationListPath(appID), nil, body)
if err != nil {
return withAppsHint(err, appIDListHint)

Check warning on line 97 in shortcuts/apps/apps_automation_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_automation_create.go#L97

Added line #L97 was not covered by tests
}
// Bearer-token redaction reverse invariant: the backend create path
// re-reads the freshly created trigger through the same read-path
// converter used by get/list — theoretically capable of returning a
// plaintext bearer token. On a fresh create the token is not yet
// enabled and this response should not carry plaintext, but redact
// for defense-in-depth and to keep every read-shaped output path
// (create / get / list / update-patch) consistently scrubbed.
redacted := redactWebhookToken(data)
trigger, _ := redacted["trigger"].(map[string]interface{})
rctx.OutFormat(redacted, nil, func(w io.Writer) {
fmt.Fprintf(w, "created trigger: %v [%v] status: %v\n",
trigger["name"], trigger["trigger_type"], trigger["status"])
})

Check warning on line 111 in shortcuts/apps/apps_automation_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_automation_create.go#L109-L111

Added lines #L109 - L111 were not covered by tests
return nil
},
}

// buildAutomationCreateBody assembles {name, description?, trigger_type, <type>_condition}.
func buildAutomationCreateBody(rctx *common.RuntimeContext) (map[string]interface{}, error) {
cliType := strings.TrimSpace(rctx.Str("trigger-type"))
snake, err := mapTriggerType(cliType)
if err != nil {
return nil, err

Check warning on line 121 in shortcuts/apps/apps_automation_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_automation_create.go#L121

Added line #L121 was not covered by tests
}
name := strings.TrimSpace(rctx.Str("name"))
if err := validateAutomationNameLen(name); err != nil {
return nil, err
}
body := map[string]interface{}{
"name": name,
"trigger_type": snake,
}
if d := strings.TrimSpace(rctx.Str("description")); d != "" {
if err := validateAutomationDescriptionLen(d); err != nil {
return nil, err
}
body["description"] = d

Check warning on line 135 in shortcuts/apps/apps_automation_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_automation_create.go#L135

Added line #L135 was not covered by tests
}
// --status is an optional passthrough: when set, backend creates + enables
// (or leaves disabled) in one call. Omitting the field lets the backend
// default (disabled) apply, matching the spec's default-disabled invariant.
if s := strings.TrimSpace(rctx.Str("status")); s != "" {
if s != "enabled" && s != "disabled" {
return nil, appsValidationParamError("--status",
"--status must be enabled or disabled, got %q", s)
}
body["status"] = s
}
switch cliType {
case "cron":
cond, err := buildCronCondition(rctx.Str("cron"), rctx.Str("timezone"))
if err != nil {
return nil, err
}
body["cron_condition"] = cond
case "record-change":
fields, err := parseFieldsFlag(rctx.Str("fields"))
if err != nil {
return nil, err

Check warning on line 157 in shortcuts/apps/apps_automation_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_automation_create.go#L157

Added line #L157 was not covered by tests
}
cond, err := buildRecordChangeCondition(rctx.Str("table"), rctx.Str("event"), fields)
if err != nil {
return nil, err
}
body["record_change_condition"] = cond

Check warning on line 163 in shortcuts/apps/apps_automation_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_automation_create.go#L163

Added line #L163 was not covered by tests
case "webhook":
ipList, err := parseIPListFlag(rctx.Str("white-ip-list"))
if err != nil {
return nil, err

Check warning on line 167 in shortcuts/apps/apps_automation_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_automation_create.go#L167

Added line #L167 was not covered by tests
}
body["webhook_condition"] = buildWebhookCondition(ipList)
case "feishu-approval":
eventType := strings.TrimSpace(rctx.Str("event-type"))
if eventType == "" {
return nil, appsValidationParamError("--event-type", "--event-type is required for feishu-approval (approval_instance/approval_task)")

Check warning on line 173 in shortcuts/apps/apps_automation_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_automation_create.go#L173

Added line #L173 was not covered by tests
}
raw := rctx.StrArray("instance-status")
if eventType == "approval_task" {
raw = rctx.StrArray("task-status")

Check warning on line 177 in shortcuts/apps/apps_automation_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_automation_create.go#L177

Added line #L177 was not covered by tests
}
// buildApprovalCondition stores the passed statuses verbatim (it only
// uppercases for validation), so normalize to the uppercase enum here to
// guarantee the backend receives canonical values (foundation review).
statuses := normalizeApprovalStatuses(raw)
cond, err := buildApprovalCondition(rctx.Str("approval-code"), eventType, statuses)
if err != nil {
return nil, err

Check warning on line 185 in shortcuts/apps/apps_automation_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_automation_create.go#L185

Added line #L185 was not covered by tests
}
body["feishu_approval_condition"] = cond
}
return body, nil
}

// normalizeApprovalStatuses trims and uppercases each status so the body carries
// the canonical enum values expected by the backend.
func normalizeApprovalStatuses(raw []string) []string {
if len(raw) == 0 {
return raw
}
out := make([]string, 0, len(raw))
for _, s := range raw {
out = append(out, strings.ToUpper(strings.TrimSpace(s)))
}
return out
}

// parseFieldsFlag parses --fields JSON array; empty → nil.
func parseFieldsFlag(raw string) ([]string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
var arr []string
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
return nil, appsValidationParamError("--fields", "--fields must be a JSON array of strings: %v", err)
}
return arr, nil
}

// parseIPListFlag parses --white-ip-list JSON array; empty → nil (field
// omitted). Each entry is validated as an IPv4/IPv6 address or CIDR, matching
// the defense-in-depth stance the record-change --event whitelist takes —
// silent acceptance of malformed IPs would let a typoed entry (`"1.1.1.1 "`
// with trailing space, `"not-an-ip"`, or `"10.0.0.256"`) narrow the webhook
// caller allowlist to nothing while the operator believes it is enforcing
// origin restrictions.
func parseIPListFlag(raw string) ([]string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
var arr []string
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
return nil, appsValidationParamError("--white-ip-list", "--white-ip-list must be a JSON array of strings: %v", err)
}
out := make([]string, 0, len(arr))
for i, entry := range arr {
trimmed := strings.TrimSpace(entry)
if trimmed == "" {
return nil, appsValidationParamError("--white-ip-list",
"--white-ip-list entry %d is empty; either drop it or provide a valid IP/CIDR", i)
}
if net.ParseIP(trimmed) != nil {
out = append(out, trimmed)
continue
}
if _, _, cidrErr := net.ParseCIDR(trimmed); cidrErr == nil {
out = append(out, trimmed)
continue
}
return nil, appsValidationParamError("--white-ip-list",
"--white-ip-list entry %d %q is not a valid IPv4/IPv6 address or CIDR block", i, entry)
}
return out, nil
}
Loading
Loading