Skip to content

Commit 7f86e32

Browse files
author
renxianwei
committed
feat(im): support feed card time sensitive
1 parent 7b695c6 commit 7f86e32

5 files changed

Lines changed: 397 additions & 0 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: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package im
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"strings"
12+
13+
"github.com/larksuite/cli/internal/output"
14+
"github.com/larksuite/cli/internal/validate"
15+
"github.com/larksuite/cli/shortcuts/common"
16+
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
17+
)
18+
19+
const feedCardBotTimeSensitivePath = "/open-apis/im/v2/feed_cards/bot_time_sentive"
20+
const feedCardTimeSensitivePathTemplate = "/open-apis/im/v2/feed_cards/:feed_card_id"
21+
22+
const feedCardTimeSensitiveScope = "im:datasync.feed_card.time_sensitive:write"
23+
24+
var ImFeedCardBotTimeSensitive = common.Shortcut{
25+
Service: "im",
26+
Command: "+feed-card-bot-time-sensitive",
27+
Description: "Set bot chat feed cards as temporarily pinned or unpinned for users; bot only",
28+
Risk: "write",
29+
Scopes: []string{feedCardTimeSensitiveScope},
30+
AuthTypes: []string{"bot"},
31+
HasFormat: true,
32+
Flags: []common.Flag{
33+
{Name: "user-ids", Desc: "recipient user IDs, comma-separated; IDs must match --user-id-type", Required: true},
34+
{Name: "user-id-type", Default: "open_id", Desc: "recipient ID type", Enum: []string{"open_id", "union_id", "user_id"}},
35+
{Name: "time-sensitive", Type: "bool", Desc: "temporary pin status; pass true to pin or false to unpin", Required: true},
36+
},
37+
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
38+
body, _ := buildFeedCardTimeSensitiveBody(runtime)
39+
return common.NewDryRunAPI().
40+
PATCH(feedCardBotTimeSensitivePath).
41+
Params(map[string]interface{}{"user_id_type": appFeedCardUserIDType(runtime)}).
42+
Body(body)
43+
},
44+
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
45+
_, err := buildFeedCardTimeSensitiveBody(runtime)
46+
return err
47+
},
48+
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
49+
body, err := buildFeedCardTimeSensitiveBody(runtime)
50+
if err != nil {
51+
return err
52+
}
53+
_, err = runtime.DoAPIJSON(http.MethodPatch, feedCardBotTimeSensitivePath,
54+
larkcore.QueryParams{"user_id_type": []string{appFeedCardUserIDType(runtime)}},
55+
body,
56+
)
57+
if err != nil {
58+
return err
59+
}
60+
return outputFeedCardTimeSensitiveResult(runtime, "", body)
61+
},
62+
}
63+
64+
var ImFeedCardTimeSensitive = common.Shortcut{
65+
Service: "im",
66+
Command: "+feed-card-time-sensitive",
67+
Description: "Set a feed card as temporarily pinned or unpinned for users by feed_card_id; bot only",
68+
Risk: "write",
69+
Scopes: []string{feedCardTimeSensitiveScope},
70+
AuthTypes: []string{"bot"},
71+
HasFormat: true,
72+
Flags: []common.Flag{
73+
{Name: "feed-card-id", Desc: "feed_card_id to update", Required: true},
74+
{Name: "user-ids", Desc: "recipient user IDs, comma-separated; IDs must match --user-id-type", Required: true},
75+
{Name: "user-id-type", Default: "open_id", Desc: "recipient ID type", Enum: []string{"open_id", "union_id", "user_id"}},
76+
{Name: "time-sensitive", Type: "bool", Desc: "temporary pin status; pass true to pin or false to unpin", Required: true},
77+
},
78+
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
79+
body, _ := buildFeedCardTimeSensitiveBody(runtime)
80+
return common.NewDryRunAPI().
81+
PATCH(feedCardTimeSensitivePathTemplate).
82+
Set("feed_card_id", strings.TrimSpace(runtime.Str("feed-card-id"))).
83+
Params(map[string]interface{}{"user_id_type": appFeedCardUserIDType(runtime)}).
84+
Body(body)
85+
},
86+
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
87+
if _, err := feedCardIDForTimeSensitive(runtime); err != nil {
88+
return err
89+
}
90+
_, err := buildFeedCardTimeSensitiveBody(runtime)
91+
return err
92+
},
93+
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
94+
feedCardID, err := feedCardIDForTimeSensitive(runtime)
95+
if err != nil {
96+
return err
97+
}
98+
body, err := buildFeedCardTimeSensitiveBody(runtime)
99+
if err != nil {
100+
return err
101+
}
102+
requestPath := fmt.Sprintf("/open-apis/im/v2/feed_cards/%s", validate.EncodePathSegment(feedCardID))
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+
return feedCardID, nil
134+
}
135+
136+
func outputFeedCardTimeSensitiveResult(runtime *common.RuntimeContext, feedCardID string, body map[string]interface{}) error {
137+
userIDs, _ := body["user_ids"].([]string)
138+
out := map[string]interface{}{
139+
"requested_user_count": len(userIDs),
140+
"time_sensitive": body["time_sensitive"],
141+
}
142+
if feedCardID != "" {
143+
out["feed_card_id"] = feedCardID
144+
}
145+
runtime.OutFormat(out, nil, func(w io.Writer) {
146+
output.PrintTable(w, []map[string]interface{}{out})
147+
})
148+
return nil
149+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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+
101+
func TestFeedCardTimeSensitiveDryRunShape(t *testing.T) {
102+
botRuntime := newTestRuntimeContext(t, map[string]string{
103+
"user-ids": "ou_1",
104+
}, map[string]bool{
105+
"time-sensitive": true,
106+
})
107+
botGot := mustMarshalDryRun(t, ImFeedCardBotTimeSensitive.DryRun(context.Background(), botRuntime))
108+
if !strings.Contains(botGot, `"method":"PATCH"`) ||
109+
!strings.Contains(botGot, `"/open-apis/im/v2/feed_cards/bot_time_sentive"`) ||
110+
!strings.Contains(botGot, `"user_id_type":"open_id"`) ||
111+
!strings.Contains(botGot, `"time_sensitive":true`) {
112+
t.Fatalf("ImFeedCardBotTimeSensitive.DryRun() = %s", botGot)
113+
}
114+
115+
cardRuntime := newTestRuntimeContext(t, map[string]string{
116+
"feed-card-id": "oc_dryrun",
117+
"user-ids": "ou_1",
118+
}, map[string]bool{
119+
"time-sensitive": false,
120+
})
121+
cardGot := mustMarshalDryRun(t, ImFeedCardTimeSensitive.DryRun(context.Background(), cardRuntime))
122+
if !strings.Contains(cardGot, `"method":"PATCH"`) ||
123+
!strings.Contains(cardGot, `"/open-apis/im/v2/feed_cards/oc_dryrun"`) ||
124+
!strings.Contains(cardGot, `"time_sensitive":false`) {
125+
t.Fatalf("ImFeedCardTimeSensitive.DryRun() = %s", cardGot)
126+
}
127+
}
128+
129+
func TestFeedCardBotTimeSensitiveExecute(t *testing.T) {
130+
factory, stdout, reg := newIMExecuteFactory(t)
131+
stub := &httpmock.Stub{
132+
Method: "PATCH",
133+
URL: feedCardBotTimeSensitivePath,
134+
Body: map[string]interface{}{
135+
"code": 0,
136+
"msg": "ok",
137+
"data": map[string]interface{}{},
138+
},
139+
}
140+
reg.Register(stub)
141+
142+
err := mountAndRunIMShortcut(t, ImFeedCardBotTimeSensitive, []string{
143+
"+feed-card-bot-time-sensitive",
144+
"--user-ids", "ou_1",
145+
"--time-sensitive",
146+
}, factory, stdout)
147+
if err != nil {
148+
t.Fatalf("mountAndRunIMShortcut() error = %v", err)
149+
}
150+
if !strings.Contains(stdout.String(), `"requested_user_count": 1`) || !strings.Contains(stdout.String(), `"time_sensitive": true`) {
151+
t.Fatalf("stdout = %s", stdout.String())
152+
}
153+
154+
var captured map[string]interface{}
155+
if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil {
156+
t.Fatalf("captured body JSON = %s, err=%v", string(stub.CapturedBody), err)
157+
}
158+
if captured["time_sensitive"] != true {
159+
t.Fatalf("captured body = %#v", captured)
160+
}
161+
}
162+
163+
func TestFeedCardTimeSensitiveExecute(t *testing.T) {
164+
factory, stdout, reg := newIMExecuteFactory(t)
165+
stub := &httpmock.Stub{
166+
Method: "PATCH",
167+
URL: "/open-apis/im/v2/feed_cards/oc_123",
168+
Body: map[string]interface{}{
169+
"code": 0,
170+
"msg": "ok",
171+
"data": map[string]interface{}{},
172+
},
173+
}
174+
reg.Register(stub)
175+
176+
err := mountAndRunIMShortcut(t, ImFeedCardTimeSensitive, []string{
177+
"+feed-card-time-sensitive",
178+
"--feed-card-id", "oc_123",
179+
"--user-ids", "ou_1",
180+
"--time-sensitive=false",
181+
}, factory, stdout)
182+
if err != nil {
183+
t.Fatalf("mountAndRunIMShortcut() error = %v", err)
184+
}
185+
if !strings.Contains(stdout.String(), `"feed_card_id": "oc_123"`) || !strings.Contains(stdout.String(), `"time_sensitive": false`) {
186+
t.Fatalf("stdout = %s", stdout.String())
187+
}
188+
189+
var captured map[string]interface{}
190+
if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil {
191+
t.Fatalf("captured body JSON = %s, err=%v", string(stub.CapturedBody), err)
192+
}
193+
userIDs, _ := captured["user_ids"].([]interface{})
194+
if len(userIDs) != 1 || captured["time_sensitive"] != false {
195+
t.Fatalf("captured body = %#v", captured)
196+
}
197+
}

shortcuts/im/shortcuts.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ func Shortcuts() []common.Shortcut {
1111
ImAppFeedCardCreate,
1212
ImAppFeedCardDelete,
1313
ImAppFeedCardUpdate,
14+
ImFeedCardBotTimeSensitive,
15+
ImFeedCardTimeSensitive,
1416
ImChatCreate,
1517
ImChatMessageList,
1618
ImChatSearch,

0 commit comments

Comments
 (0)