Skip to content

Commit 4a31645

Browse files
committed
feat(im): support feed card time sensitive
Add bot-only IM shortcuts for feed card temporary pinning: +feed-card-bot-time-sensitive wraps PATCH /open-apis/im/v2/feed_cards/bot_time_sentive, and +feed-card-time-sensitive wraps PATCH /open-apis/im/v2/feed_cards/{feed_card_id}. Both shortcuts accept --user-ids, --user-id-type, and an explicit --time-sensitive flag so true pins and false unpins are both represented in the request body. The feed_card_id path segment is escaped before execution. Add unit tests for payload construction, validation, dry-run shape, and execute requests, plus CLI dry-run E2E coverage for both new commands. Address PR review feedback by registering the app feed card live workflow cleanup immediately after a successful create exit before stdout assertions can fail. Verification: go test ./shortcuts/im -run 'TestBuildFeedCard|TestFeedCard|TestShortcuts' -count=1; go test ./shortcuts/im ./shortcuts -count=1; go vet ./shortcuts/im; go build -o /tmp/lark-cli-feed-card-test .; env LARK_CLI_BIN=/tmp/lark-cli-feed-card-test go test ./tests/cli_e2e/im -run 'TestIM_(AppFeedCard|FeedCard).*DryRun' -count=1.
1 parent 7b695c6 commit 4a31645

5 files changed

Lines changed: 475 additions & 7 deletions

File tree

shortcuts/im/helpers_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,8 @@ func TestShortcuts(t *testing.T) {
647647
"+app-feed-card-create",
648648
"+app-feed-card-delete",
649649
"+app-feed-card-update",
650+
"+feed-card-bot-time-sensitive",
651+
"+feed-card-time-sensitive",
650652
"+chat-create",
651653
"+chat-messages-list",
652654
"+chat-search",
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package im
5+
6+
import (
7+
"context"
8+
"io"
9+
"net/http"
10+
"strings"
11+
12+
"github.com/larksuite/cli/internal/output"
13+
"github.com/larksuite/cli/internal/validate"
14+
"github.com/larksuite/cli/shortcuts/common"
15+
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
16+
)
17+
18+
const feedCardTimeSensitiveBasePath = "/open-apis/im/v2/feed_cards"
19+
const feedCardBotTimeSensitiveSegment = "bot_time_sentive"
20+
const feedCardBotTimeSensitivePath = feedCardTimeSensitiveBasePath + "/" + feedCardBotTimeSensitiveSegment
21+
const feedCardTimeSensitivePathTemplate = feedCardTimeSensitiveBasePath + "/:feed_card_id"
22+
23+
const feedCardTimeSensitiveScope = "im:datasync.feed_card.time_sensitive:write"
24+
25+
var ImFeedCardBotTimeSensitive = common.Shortcut{
26+
Service: "im",
27+
Command: "+feed-card-bot-time-sensitive",
28+
Description: "Set bot chat feed cards as temporarily pinned or unpinned for users; bot only",
29+
Risk: "write",
30+
Scopes: []string{feedCardTimeSensitiveScope},
31+
AuthTypes: []string{"bot"},
32+
HasFormat: true,
33+
Flags: []common.Flag{
34+
{Name: "user-ids", Desc: "recipient user IDs, comma-separated; IDs must match --user-id-type", Required: true},
35+
{Name: "user-id-type", Default: "open_id", Desc: "recipient ID type", Enum: []string{"open_id", "union_id", "user_id"}},
36+
{Name: "time-sensitive", Type: "bool", Desc: "temporary pin status; pass true to pin or false to unpin", Required: true},
37+
},
38+
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
39+
body, _ := buildFeedCardTimeSensitiveBody(runtime)
40+
return common.NewDryRunAPI().
41+
PATCH(feedCardBotTimeSensitivePath).
42+
Params(map[string]interface{}{"user_id_type": appFeedCardUserIDType(runtime)}).
43+
Body(body)
44+
},
45+
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
46+
_, err := buildFeedCardTimeSensitiveBody(runtime)
47+
return err
48+
},
49+
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
50+
body, err := buildFeedCardTimeSensitiveBody(runtime)
51+
if err != nil {
52+
return err
53+
}
54+
_, err = runtime.DoAPIJSON(http.MethodPatch, feedCardBotTimeSensitivePath,
55+
larkcore.QueryParams{"user_id_type": []string{appFeedCardUserIDType(runtime)}},
56+
body,
57+
)
58+
if err != nil {
59+
return err
60+
}
61+
return outputFeedCardTimeSensitiveResult(runtime, "", body)
62+
},
63+
}
64+
65+
var ImFeedCardTimeSensitive = common.Shortcut{
66+
Service: "im",
67+
Command: "+feed-card-time-sensitive",
68+
Description: "Set a feed card as temporarily pinned or unpinned for users by feed_card_id; bot only",
69+
Risk: "write",
70+
Scopes: []string{feedCardTimeSensitiveScope},
71+
AuthTypes: []string{"bot"},
72+
HasFormat: true,
73+
Flags: []common.Flag{
74+
{Name: "feed-card-id", Desc: "feed_card_id to update", Required: true},
75+
{Name: "user-ids", Desc: "recipient user IDs, comma-separated; IDs must match --user-id-type", Required: true},
76+
{Name: "user-id-type", Default: "open_id", Desc: "recipient ID type", Enum: []string{"open_id", "union_id", "user_id"}},
77+
{Name: "time-sensitive", Type: "bool", Desc: "temporary pin status; pass true to pin or false to unpin", Required: true},
78+
},
79+
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
80+
body, _ := buildFeedCardTimeSensitiveBody(runtime)
81+
return common.NewDryRunAPI().
82+
PATCH(feedCardTimeSensitivePathTemplate).
83+
Set("feed_card_id", strings.TrimSpace(runtime.Str("feed-card-id"))).
84+
Params(map[string]interface{}{"user_id_type": appFeedCardUserIDType(runtime)}).
85+
Body(body)
86+
},
87+
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
88+
if _, err := feedCardIDForTimeSensitive(runtime); err != nil {
89+
return err
90+
}
91+
_, err := buildFeedCardTimeSensitiveBody(runtime)
92+
return err
93+
},
94+
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
95+
requestPath, feedCardID, err := feedCardTimeSensitiveRequestPath(runtime)
96+
if err != nil {
97+
return err
98+
}
99+
body, err := buildFeedCardTimeSensitiveBody(runtime)
100+
if err != nil {
101+
return err
102+
}
103+
_, err = runtime.DoAPIJSON(http.MethodPatch, requestPath,
104+
larkcore.QueryParams{"user_id_type": []string{appFeedCardUserIDType(runtime)}},
105+
body,
106+
)
107+
if err != nil {
108+
return err
109+
}
110+
return outputFeedCardTimeSensitiveResult(runtime, feedCardID, body)
111+
},
112+
}
113+
114+
func buildFeedCardTimeSensitiveBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
115+
userIDs := common.SplitCSV(runtime.Str("user-ids"))
116+
if err := validateAppFeedUserIDs(userIDs, appFeedCardUserIDType(runtime), "--user-ids"); err != nil {
117+
return nil, err
118+
}
119+
if !flagChanged(runtime, "time-sensitive") {
120+
return nil, output.ErrValidation("--time-sensitive is required; pass --time-sensitive=true to pin or --time-sensitive=false to unpin")
121+
}
122+
return map[string]interface{}{
123+
"user_ids": userIDs,
124+
"time_sensitive": runtime.Bool("time-sensitive"),
125+
}, nil
126+
}
127+
128+
func feedCardIDForTimeSensitive(runtime *common.RuntimeContext) (string, error) {
129+
feedCardID := strings.TrimSpace(runtime.Str("feed-card-id"))
130+
if feedCardID == "" {
131+
return "", output.ErrValidation("--feed-card-id is required")
132+
}
133+
if isReservedFeedCardTimeSensitiveSegment(feedCardID) {
134+
return "", output.ErrWithHint(output.ExitValidation, "validation",
135+
`--feed-card-id "bot_time_sentive" conflicts with the bot time sensitive endpoint`,
136+
`use "lark-cli im +feed-card-bot-time-sensitive" for /open-apis/im/v2/feed_cards/bot_time_sentive, or pass a real feed_card_id to "lark-cli im +feed-card-time-sensitive"`)
137+
}
138+
if !strings.HasPrefix(feedCardID, "oc_") {
139+
return "", output.ErrWithHint(output.ExitValidation, "validation",
140+
`--feed-card-id must be a group feed_card_id starting with "oc_"`,
141+
`use "lark-cli im +feed-card-bot-time-sensitive" for the bot conversation endpoint, or pass a group feed_card_id such as oc_xxx to "lark-cli im +feed-card-time-sensitive"`)
142+
}
143+
return feedCardID, nil
144+
}
145+
146+
func feedCardTimeSensitiveRequestPath(runtime *common.RuntimeContext) (string, string, error) {
147+
feedCardID, err := feedCardIDForTimeSensitive(runtime)
148+
if err != nil {
149+
return "", "", err
150+
}
151+
return feedCardTimeSensitiveBasePath + "/" + validate.EncodePathSegment(feedCardID), feedCardID, nil
152+
}
153+
154+
func isReservedFeedCardTimeSensitiveSegment(feedCardID string) bool {
155+
return feedCardID == feedCardBotTimeSensitiveSegment
156+
}
157+
158+
func outputFeedCardTimeSensitiveResult(runtime *common.RuntimeContext, feedCardID string, body map[string]interface{}) error {
159+
userIDs, _ := body["user_ids"].([]string)
160+
out := map[string]interface{}{
161+
"requested_user_count": len(userIDs),
162+
"time_sensitive": body["time_sensitive"],
163+
}
164+
if feedCardID != "" {
165+
out["feed_card_id"] = feedCardID
166+
}
167+
runtime.OutFormat(out, nil, func(w io.Writer) {
168+
output.PrintTable(w, []map[string]interface{}{out})
169+
})
170+
return nil
171+
}
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package im
5+
6+
import (
7+
"context"
8+
"encoding/json"
9+
"strings"
10+
"testing"
11+
12+
"github.com/larksuite/cli/internal/httpmock"
13+
)
14+
15+
func TestBuildFeedCardTimeSensitiveBody(t *testing.T) {
16+
runtime := newTestRuntimeContext(t, map[string]string{
17+
"user-ids": "ou_1, ou_2",
18+
}, map[string]bool{
19+
"time-sensitive": true,
20+
})
21+
22+
body, err := buildFeedCardTimeSensitiveBody(runtime)
23+
if err != nil {
24+
t.Fatalf("buildFeedCardTimeSensitiveBody() error = %v", err)
25+
}
26+
userIDs, _ := body["user_ids"].([]string)
27+
if len(userIDs) != 2 || userIDs[0] != "ou_1" || userIDs[1] != "ou_2" {
28+
t.Fatalf("user_ids = %#v", body["user_ids"])
29+
}
30+
if body["time_sensitive"] != true {
31+
t.Fatalf("time_sensitive = %#v", body["time_sensitive"])
32+
}
33+
}
34+
35+
func TestBuildFeedCardTimeSensitiveBodyAcceptsFalse(t *testing.T) {
36+
runtime := newTestRuntimeContext(t, map[string]string{
37+
"user-ids": "ou_1",
38+
}, map[string]bool{
39+
"time-sensitive": false,
40+
})
41+
42+
body, err := buildFeedCardTimeSensitiveBody(runtime)
43+
if err != nil {
44+
t.Fatalf("buildFeedCardTimeSensitiveBody() error = %v", err)
45+
}
46+
if body["time_sensitive"] != false {
47+
t.Fatalf("time_sensitive = %#v", body["time_sensitive"])
48+
}
49+
}
50+
51+
func TestBuildFeedCardTimeSensitiveBodyValidation(t *testing.T) {
52+
invalidOpenIDErr := invalidOpenIDError(t)
53+
tests := []struct {
54+
name string
55+
strFlags map[string]string
56+
boolFlags map[string]bool
57+
wantErr string
58+
}{
59+
{
60+
name: "missing time sensitive",
61+
strFlags: map[string]string{"user-ids": "ou_1"},
62+
wantErr: "--time-sensitive is required",
63+
},
64+
{
65+
name: "invalid recipient open id",
66+
strFlags: map[string]string{"user-ids": "bad_user"},
67+
boolFlags: map[string]bool{"time-sensitive": true},
68+
wantErr: invalidOpenIDErr,
69+
},
70+
{
71+
name: "union id bypasses open id prefix validation",
72+
strFlags: map[string]string{"user-ids": "onion_1", "user-id-type": "union_id"},
73+
boolFlags: map[string]bool{"time-sensitive": true},
74+
},
75+
}
76+
77+
for _, tt := range tests {
78+
t.Run(tt.name, func(t *testing.T) {
79+
runtime := newTestRuntimeContext(t, tt.strFlags, tt.boolFlags)
80+
_, err := buildFeedCardTimeSensitiveBody(runtime)
81+
if tt.wantErr == "" {
82+
if err != nil {
83+
t.Fatalf("unexpected error = %v", err)
84+
}
85+
return
86+
}
87+
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
88+
t.Fatalf("error = %v, want substring %q", err, tt.wantErr)
89+
}
90+
})
91+
}
92+
}
93+
94+
func TestFeedCardIDForTimeSensitiveValidation(t *testing.T) {
95+
runtime := newTestRuntimeContext(t, map[string]string{"feed-card-id": " "}, nil)
96+
if _, err := feedCardIDForTimeSensitive(runtime); err == nil || !strings.Contains(err.Error(), "--feed-card-id is required") {
97+
t.Fatalf("feedCardIDForTimeSensitive() error = %v", err)
98+
}
99+
100+
runtime = newTestRuntimeContext(t, map[string]string{"feed-card-id": "bot_time_sentive"}, nil)
101+
if _, err := feedCardIDForTimeSensitive(runtime); err == nil ||
102+
!strings.Contains(err.Error(), "conflicts with the bot time sensitive endpoint") {
103+
t.Fatalf("feedCardIDForTimeSensitive() reserved segment error = %v", err)
104+
}
105+
106+
runtime = newTestRuntimeContext(t, map[string]string{"feed-card-id": "om_123"}, nil)
107+
if _, err := feedCardIDForTimeSensitive(runtime); err == nil ||
108+
!strings.Contains(err.Error(), `starting with "oc_"`) {
109+
t.Fatalf("feedCardIDForTimeSensitive() non-group feed card error = %v", err)
110+
}
111+
}
112+
113+
func TestFeedCardTimeSensitiveRequestPathRejectsReservedSegment(t *testing.T) {
114+
runtime := newTestRuntimeContext(t, map[string]string{"feed-card-id": "bot_time_sentive"}, nil)
115+
if _, _, err := feedCardTimeSensitiveRequestPath(runtime); err == nil ||
116+
!strings.Contains(err.Error(), "conflicts with the bot time sensitive endpoint") {
117+
t.Fatalf("feedCardTimeSensitiveRequestPath() error = %v", err)
118+
}
119+
}
120+
121+
func TestFeedCardTimeSensitiveDryRunShape(t *testing.T) {
122+
botRuntime := newTestRuntimeContext(t, map[string]string{
123+
"user-ids": "ou_1",
124+
}, map[string]bool{
125+
"time-sensitive": true,
126+
})
127+
botGot := mustMarshalDryRun(t, ImFeedCardBotTimeSensitive.DryRun(context.Background(), botRuntime))
128+
if !strings.Contains(botGot, `"method":"PATCH"`) ||
129+
!strings.Contains(botGot, `"/open-apis/im/v2/feed_cards/bot_time_sentive"`) ||
130+
!strings.Contains(botGot, `"user_id_type":"open_id"`) ||
131+
!strings.Contains(botGot, `"time_sensitive":true`) {
132+
t.Fatalf("ImFeedCardBotTimeSensitive.DryRun() = %s", botGot)
133+
}
134+
135+
cardRuntime := newTestRuntimeContext(t, map[string]string{
136+
"feed-card-id": "oc_dryrun",
137+
"user-ids": "ou_1",
138+
}, map[string]bool{
139+
"time-sensitive": false,
140+
})
141+
cardGot := mustMarshalDryRun(t, ImFeedCardTimeSensitive.DryRun(context.Background(), cardRuntime))
142+
if !strings.Contains(cardGot, `"method":"PATCH"`) ||
143+
!strings.Contains(cardGot, `"/open-apis/im/v2/feed_cards/oc_dryrun"`) ||
144+
!strings.Contains(cardGot, `"time_sensitive":false`) {
145+
t.Fatalf("ImFeedCardTimeSensitive.DryRun() = %s", cardGot)
146+
}
147+
}
148+
149+
func TestFeedCardBotTimeSensitiveExecute(t *testing.T) {
150+
factory, stdout, reg := newIMExecuteFactory(t)
151+
stub := &httpmock.Stub{
152+
Method: "PATCH",
153+
URL: feedCardBotTimeSensitivePath,
154+
Body: map[string]interface{}{
155+
"code": 0,
156+
"msg": "ok",
157+
"data": map[string]interface{}{},
158+
},
159+
}
160+
reg.Register(stub)
161+
162+
err := mountAndRunIMShortcut(t, ImFeedCardBotTimeSensitive, []string{
163+
"+feed-card-bot-time-sensitive",
164+
"--user-ids", "ou_1",
165+
"--time-sensitive",
166+
}, factory, stdout)
167+
if err != nil {
168+
t.Fatalf("mountAndRunIMShortcut() error = %v", err)
169+
}
170+
if !strings.Contains(stdout.String(), `"requested_user_count": 1`) || !strings.Contains(stdout.String(), `"time_sensitive": true`) {
171+
t.Fatalf("stdout = %s", stdout.String())
172+
}
173+
174+
var captured map[string]interface{}
175+
if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil {
176+
t.Fatalf("captured body JSON = %s, err=%v", string(stub.CapturedBody), err)
177+
}
178+
if captured["time_sensitive"] != true {
179+
t.Fatalf("captured body = %#v", captured)
180+
}
181+
}
182+
183+
func TestFeedCardTimeSensitiveExecute(t *testing.T) {
184+
factory, stdout, reg := newIMExecuteFactory(t)
185+
stub := &httpmock.Stub{
186+
Method: "PATCH",
187+
URL: "/open-apis/im/v2/feed_cards/oc_123",
188+
Body: map[string]interface{}{
189+
"code": 0,
190+
"msg": "ok",
191+
"data": map[string]interface{}{},
192+
},
193+
}
194+
reg.Register(stub)
195+
196+
err := mountAndRunIMShortcut(t, ImFeedCardTimeSensitive, []string{
197+
"+feed-card-time-sensitive",
198+
"--feed-card-id", "oc_123",
199+
"--user-ids", "ou_1",
200+
"--time-sensitive=false",
201+
}, factory, stdout)
202+
if err != nil {
203+
t.Fatalf("mountAndRunIMShortcut() error = %v", err)
204+
}
205+
if !strings.Contains(stdout.String(), `"feed_card_id": "oc_123"`) || !strings.Contains(stdout.String(), `"time_sensitive": false`) {
206+
t.Fatalf("stdout = %s", stdout.String())
207+
}
208+
209+
var captured map[string]interface{}
210+
if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil {
211+
t.Fatalf("captured body JSON = %s, err=%v", string(stub.CapturedBody), err)
212+
}
213+
userIDs, _ := captured["user_ids"].([]interface{})
214+
if len(userIDs) != 1 || captured["time_sensitive"] != false {
215+
t.Fatalf("captured body = %#v", captured)
216+
}
217+
}

0 commit comments

Comments
 (0)