├── .gitattributes
├── .gitignore
├── LICENSE
├── README.md
├── api.go
├── bot.go
├── bot_test.go
├── go.mod
├── go.sum
├── md
└── md.go
├── msg_card.go
├── msg_card_template.go
├── msg_card_test.go
├── msg_groupbusinesscard.go
├── msg_image.go
├── msg_richtext.go
├── msg_text.go
├── x.go
└── x_test.go
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 | *.go -text diff=golang eol=lf
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ### IntelliJ project files
2 | /.idea/
3 |
4 |
5 |
6 | ### Golang
7 | # Binaries for programs and plugins
8 | *.exe
9 | *.exe~
10 | *.dll
11 | *.so
12 | *.dylib
13 |
14 | # Test binary, built with `go test -c`
15 | *.test
16 |
17 | # Output of the go coverage tool, specifically when used with LiteIDE
18 | *.out
19 |
20 | # Go workspace file
21 | go.work
22 |
23 |
24 |
25 | ### macOS
26 | # General
27 | .DS_Store
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 electricbubble
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # feishu-bot-api
2 |
3 | 飞书-群机器人-API
4 |
5 | ## 安装
6 |
7 | ```shell
8 | go get github.com/electricbubble/feishu-bot-api/v2
9 | ```
10 |
11 | ## 使用
12 |
13 | [bot_test.go](bot_test.go)
14 |
--------------------------------------------------------------------------------
/api.go:
--------------------------------------------------------------------------------
1 | package feishu_bot_api
2 |
3 | import (
4 | "crypto/hmac"
5 | "crypto/sha256"
6 | "encoding/base64"
7 | "encoding/json"
8 | "path"
9 | "strconv"
10 | "strings"
11 | "time"
12 |
13 | "github.com/electricbubble/xhttpclient"
14 | "golang.org/x/time/rate"
15 | )
16 |
17 | type Bot interface {
18 | // SendText 发送文本消息
19 | //
20 | // https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot?lang=zh-CN#756b882f
21 | //
22 | // @指定人: TextAtPerson
23 | // @所有人: TextAtEveryone
24 | SendText(content string) error
25 |
26 | // SendRichText 发送富文本消息
27 | //
28 | // https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot?lang=zh-CN#f62e72d5
29 | SendRichText(rt *RichTextBuilder, multiLanguage ...*RichTextBuilder) error
30 |
31 | // SendGroupBusinessCard 发送群名片
32 | //
33 | // https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot?lang=zh-CN#897b5321
34 | //
35 | // 群 ID 获取方式: https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/chat-id-description
36 | SendGroupBusinessCard(chatID string) error
37 |
38 | // SendImage 发送图片
39 | //
40 | // https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot?lang=zh-CN#132a114c
41 | //
42 | // image_key 获取方式: https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/image/create
43 | SendImage(imgKey string) error
44 |
45 | // SendCard 发送消息卡片
46 | //
47 | // https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot?lang=zh-CN#4996824a
48 | SendCard(globalConf *CardGlobalConfig, card *CardBuilder, multiLanguage ...*CardBuilder) error
49 |
50 | // SendCardViaTemplate 使用卡片 ID 发送消息
51 | //
52 | // https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/send-message-card/send-message-using-card-id
53 | SendCardViaTemplate(id string, variables any) error
54 |
55 | SendMessage(msg Message) error
56 | }
57 |
58 | type Message interface {
59 | Apply(body *MessageBody) error
60 | }
61 |
62 | type MessageBody struct {
63 | MsgType string `json:"msg_type"`
64 | Content *MessageBodyContent `json:"content,omitempty"`
65 | Card *json.RawMessage `json:"card,omitempty"`
66 | }
67 |
68 | type MessageBodyContent struct {
69 | Text string `json:"text,omitempty"`
70 | Post *json.RawMessage `json:"post,omitempty"`
71 | ShareChatID string `json:"share_chat_id,omitempty"`
72 | ImageKey string `json:"image_key,omitempty"`
73 | }
74 |
75 | type MessageBodyCard struct {
76 | Header *json.RawMessage `json:"header,omitempty"`
77 | Elements *json.RawMessage `json:"elements,omitempty"`
78 | I18nElements *json.RawMessage `json:"i18n_elements"`
79 | Config *json.RawMessage `json:"config,omitempty"`
80 | CardLink *json.RawMessage `json:"card_link,omitempty"`
81 | }
82 |
83 | type MessageBodyCardTemplate struct {
84 | Type string `json:"type"`
85 | Data MessageBodyCardTemplateData `json:"data"`
86 | }
87 |
88 | type MessageBodyCardTemplateData struct {
89 | TemplateID string `json:"template_id"`
90 | TemplateVariable *json.RawMessage `json:"template_variable,omitempty"`
91 | }
92 |
93 | func NewBot(webhook string, opts *BotOptions) Bot {
94 | if opts == nil {
95 | opts = &BotOptions{}
96 | }
97 | opts.init()
98 |
99 | b := &bot{opts: opts}
100 |
101 | if s := strings.TrimSpace(webhook); strings.Contains(s, "/open-apis/bot") {
102 | b.webhookAccessToken = path.Base(s)
103 | } else {
104 | b.webhookAccessToken = s
105 | }
106 |
107 | if opts.limiterEnabled() {
108 | b.limiterSecond = rate.NewLimiter(rate.Limit(opts.LimiterPerSecond), opts.LimiterPerSecond)
109 | b.limiterMinute = rate.NewLimiter(rate.Every(time.Minute/time.Duration(opts.LimiterPerMinute)), opts.LimiterPerMinute)
110 | }
111 |
112 | b.cli = xhttpclient.NewClient().BaseURL(opts.BaseURL)
113 |
114 | return b
115 | }
116 |
117 | type BotOptions struct {
118 | BaseURL string
119 | LimiterPerSecond, LimiterPerMinute int
120 | SecretKey string
121 |
122 | HookAfterMessageApply func(body *MessageBody) error
123 | }
124 |
125 | func NewBotOptions() *BotOptions { return &BotOptions{} }
126 |
127 | func (opts *BotOptions) init() {
128 | if strings.TrimSpace(opts.BaseURL) == "" {
129 | opts.BaseURL = "https://open.feishu.cn"
130 | }
131 |
132 | if opts.limiterEnabled() {
133 | if opts.LimiterPerSecond == 0 {
134 | opts.LimiterPerSecond = 5
135 | }
136 |
137 | if opts.LimiterPerMinute == 0 {
138 | opts.LimiterPerMinute = 100
139 | }
140 | }
141 |
142 | opts.SecretKey = strings.TrimSpace(opts.SecretKey)
143 | }
144 |
145 | func (opts *BotOptions) limiterEnabled() bool {
146 | if opts.LimiterPerSecond <= -1 || opts.LimiterPerMinute <= -1 {
147 | return false
148 | }
149 |
150 | return true
151 | }
152 |
153 | func (opts *BotOptions) SetBaseURL(s string) *BotOptions {
154 | opts.BaseURL = strings.TrimSpace(s)
155 | return opts
156 | }
157 |
158 | func (opts *BotOptions) SetLimiterPerSecond(n int) *BotOptions {
159 | opts.LimiterPerSecond = n
160 | return opts
161 | }
162 |
163 | func (opts *BotOptions) SetLimiterPerMinute(n int) *BotOptions {
164 | opts.LimiterPerMinute = n
165 | return opts
166 | }
167 |
168 | func (opts *BotOptions) SetSecretKey(s string) *BotOptions {
169 | opts.SecretKey = s
170 | return opts
171 | }
172 |
173 | func (opts *BotOptions) SetHookAfterMessageApply(f func(body *MessageBody) error) *BotOptions {
174 | opts.HookAfterMessageApply = f
175 | return opts
176 | }
177 |
178 | // --------------------------------------------------------------------------------
179 |
180 | // 签名校验
181 | //
182 | // https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot#3c6592d6
183 | func genSignature(timestamp int64, secretKey string) (string, error) {
184 | s := strconv.FormatInt(timestamp, 10) + "\n" + secretKey
185 |
186 | var data []byte
187 | h := hmac.New(sha256.New, []byte(s))
188 | if _, err := h.Write(data); err != nil {
189 | return "", err
190 | }
191 |
192 | return base64.StdEncoding.EncodeToString(h.Sum(nil)), nil
193 | }
194 |
195 | // --------------------------------------------------------------------------------
196 |
197 | type Language string
198 |
199 | const (
200 | LanguageChinese Language = "zh_cn"
201 | LanguageEnglish Language = "en_us"
202 | LanguageJapanese Language = "ja_jp"
203 | )
204 |
--------------------------------------------------------------------------------
/bot.go:
--------------------------------------------------------------------------------
1 | package feishu_bot_api
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "time"
7 |
8 | "github.com/electricbubble/xhttpclient"
9 | "golang.org/x/time/rate"
10 | )
11 |
12 | type bot struct {
13 | webhookAccessToken string
14 | opts *BotOptions
15 | limiterSecond, limiterMinute *rate.Limiter
16 | cli *xhttpclient.XClient
17 | }
18 |
19 | type apiRequest struct {
20 | MessageBody
21 | Timestamp int64 `json:"timestamp,omitempty"`
22 | Sign string `json:"sign,omitempty"`
23 | }
24 |
25 | type apiResponse struct {
26 | Code int `json:"code"`
27 | Msg string `json:"msg"`
28 | Data any `json:"data"`
29 | }
30 |
31 | func (b *bot) SendText(content string) error {
32 | return b.SendMessage(textMessage(content))
33 | }
34 |
35 | func (b *bot) SendRichText(rt *RichTextBuilder, multiLanguage ...*RichTextBuilder) error {
36 | return b.SendMessage(richTextMessage(append([]*RichTextBuilder{rt}, multiLanguage...)))
37 | }
38 |
39 | func (b *bot) SendGroupBusinessCard(chatID string) error {
40 | return b.SendMessage(groupBusinessCard(chatID))
41 | }
42 |
43 | func (b *bot) SendImage(imgKey string) error {
44 | return b.SendMessage(imageMessage(imgKey))
45 | }
46 |
47 | func (b *bot) SendCard(globalConf *CardGlobalConfig, card *CardBuilder, multiLanguage ...*CardBuilder) error {
48 | return b.SendMessage(cardMessage{
49 | globalConf: globalConf,
50 | builders: append([]*CardBuilder{card}, multiLanguage...),
51 | })
52 | }
53 | func (b *bot) SendCardViaTemplate(id string, variables any) error {
54 | return b.SendMessage(cardMessageViaTemplate{id: id, variables: variables})
55 | }
56 |
57 | func (b *bot) SendMessage(msg Message) (err error) {
58 | if b.opts.limiterEnabled() {
59 | if err := b.wait(); err != nil {
60 | return err
61 | }
62 | }
63 |
64 | var req apiRequest
65 | if s := b.opts.SecretKey; s != "" {
66 | req.Timestamp = time.Now().Unix()
67 | if req.Sign, err = genSignature(req.Timestamp, s); err != nil {
68 | return fmt.Errorf("gen signature: %w", err)
69 | }
70 | }
71 |
72 | if err := msg.Apply(&req.MessageBody); err != nil {
73 | return fmt.Errorf("apply: %w", err)
74 | }
75 |
76 | if f := b.opts.HookAfterMessageApply; f != nil {
77 | if err := f(&req.MessageBody); err != nil {
78 | return fmt.Errorf("hook(AfterMessageApply): %w", err)
79 | }
80 | }
81 |
82 | var resp apiResponse
83 | _, respBody, err := b.cli.Do(&resp, nil,
84 | xhttpclient.
85 | NewPost().
86 | Path("/open-apis/bot/v2/hook", b.webhookAccessToken).
87 | Body(req),
88 | )
89 | if err != nil {
90 | return fmt.Errorf("unexpected: %w (resp body: %s)", err, respBody)
91 | }
92 |
93 | if resp.Code != 0 {
94 | return fmt.Errorf("api error: %s", respBody)
95 | }
96 |
97 | return
98 | }
99 |
100 | func (b *bot) wait() error {
101 |
102 | REDO:
103 |
104 | now := time.Now()
105 |
106 | {
107 | ts := time.Unix(now.Unix(), 0)
108 |
109 | if b.limiterSecond.TokensAt(ts) <= 0 {
110 | time.Sleep(ts.Add(time.Second).Sub(now))
111 | goto REDO
112 | }
113 |
114 | rs := b.limiterSecond.ReserveN(ts, 1)
115 | if !rs.OK() {
116 | return errors.New("limiter(second): not allowed to act")
117 | }
118 |
119 | switch d := rs.DelayFrom(ts); d {
120 | case rate.InfDuration:
121 | return errors.New("limiter(second): cannot grant the token")
122 | case 0:
123 | default:
124 | time.Sleep(d)
125 | }
126 | }
127 |
128 | {
129 | tm := time.Unix(now.Unix()-int64(now.Second()), 0)
130 |
131 | if b.limiterMinute.TokensAt(tm) <= 0 {
132 | time.Sleep(tm.Add(time.Minute).Sub(now))
133 | goto REDO
134 | }
135 |
136 | rm := b.limiterMinute.ReserveN(tm, 1)
137 | if !rm.OK() {
138 | return errors.New("limiter(minute): not allowed to act")
139 | }
140 | switch d := rm.DelayFrom(tm); d {
141 | case rate.InfDuration:
142 | return errors.New("limiter(minute): cannot grant the token")
143 | case 0:
144 | default:
145 | time.Sleep(d)
146 | }
147 | }
148 |
149 | return nil
150 | }
151 |
--------------------------------------------------------------------------------
/bot_test.go:
--------------------------------------------------------------------------------
1 | package feishu_bot_api
2 |
3 | import (
4 | "bytes"
5 | "cmp"
6 | "fmt"
7 | "os"
8 | "slices"
9 | "sync"
10 | "sync/atomic"
11 | "testing"
12 | "time"
13 |
14 | "github.com/electricbubble/feishu-bot-api/v2/md"
15 | )
16 |
17 | func Test_bot_wait(t *testing.T) {
18 | const offsetDuration = 50 * time.Millisecond
19 |
20 | tests := []struct {
21 | count int
22 | opts *BotOptions
23 | wg *sync.WaitGroup
24 | wantDuration time.Duration
25 | }{
26 | {
27 | count: 10,
28 | opts: &BotOptions{},
29 | wantDuration: 1 * time.Second,
30 | },
31 | {
32 | count: 10,
33 | opts: &BotOptions{LimiterPerSecond: 5, LimiterPerMinute: 100},
34 | wantDuration: 1 * time.Second,
35 | },
36 | {
37 | count: 11,
38 | opts: &BotOptions{LimiterPerSecond: 5, LimiterPerMinute: 100},
39 | wantDuration: 2 * time.Second,
40 | },
41 | {
42 | count: 5,
43 | opts: &BotOptions{LimiterPerSecond: 5, LimiterPerMinute: 100},
44 | wantDuration: 0,
45 | },
46 | {
47 | count: 15,
48 | opts: &BotOptions{LimiterPerSecond: 5, LimiterPerMinute: 100},
49 | wantDuration: 2 * time.Second,
50 | },
51 | {
52 | count: 16,
53 | opts: &BotOptions{LimiterPerSecond: 5, LimiterPerMinute: 100},
54 | wantDuration: 3 * time.Second,
55 | },
56 | {
57 | count: 1,
58 | opts: &BotOptions{LimiterPerSecond: 1, LimiterPerMinute: 5},
59 | wantDuration: 0,
60 | },
61 | {
62 | count: 3,
63 | opts: &BotOptions{LimiterPerSecond: 1, LimiterPerMinute: 5},
64 | wantDuration: 2 * time.Second,
65 | },
66 | {
67 | count: 5,
68 | opts: &BotOptions{LimiterPerSecond: 1, LimiterPerMinute: 5},
69 | wantDuration: 4 * time.Second,
70 | },
71 | {
72 | count: 121,
73 | opts: &BotOptions{LimiterPerSecond: 5, LimiterPerMinute: 100},
74 | wantDuration: 1*time.Minute + 4*time.Second,
75 | },
76 | {
77 | count: 6,
78 | opts: &BotOptions{LimiterPerSecond: 1, LimiterPerMinute: 5},
79 | wantDuration: 1 * time.Minute,
80 | },
81 | {
82 | count: 15,
83 | opts: &BotOptions{LimiterPerSecond: 1, LimiterPerMinute: 5},
84 | wantDuration: 2*time.Minute + 4*time.Second,
85 | },
86 | {
87 | count: 15,
88 | opts: &BotOptions{LimiterPerSecond: 2, LimiterPerMinute: 10},
89 | wg: new(sync.WaitGroup),
90 | wantDuration: 1*time.Minute + 2*time.Second,
91 | },
92 | }
93 |
94 | var wg sync.WaitGroup
95 |
96 | for _, tt := range tests {
97 | tt := tt
98 |
99 | wg.Add(1)
100 | go func() {
101 | defer wg.Done()
102 |
103 | b := NewBot("", tt.opts).(*bot)
104 | name := fmt.Sprintf("count-%d_s-%d_m-%d", tt.count, b.opts.LimiterPerSecond, b.opts.LimiterPerMinute)
105 |
106 | t.Run(name, func(t *testing.T) {
107 |
108 | {
109 | var (
110 | now = time.Now()
111 | next time.Time
112 | )
113 | if tt.count <= b.opts.LimiterPerMinute {
114 | next = time.Unix(now.Unix(), 0).Add(time.Second)
115 | } else {
116 | next = time.Unix(now.Unix()-int64(now.Second()), 0).Add(time.Minute)
117 | }
118 | time.Sleep(next.Sub(now))
119 | }
120 |
121 | var (
122 | start = time.Now()
123 | ms, mm sync.Map
124 | bufDebug bytes.Buffer
125 | )
126 |
127 | for i := 1; i <= tt.count; i++ {
128 | if tt.wg != nil {
129 | tt.wg.Add(1)
130 | go func() {
131 | defer tt.wg.Done()
132 |
133 | if err := b.wait(); err != nil {
134 | t.Errorf("Received unexpected error:\n%+v", err)
135 | }
136 | now := time.Now()
137 | {
138 | counter := new(atomic.Int32)
139 | _v, _ := ms.LoadOrStore(now.Unix(), counter)
140 | counter = _v.(*atomic.Int32)
141 | counter.Add(1)
142 | }
143 | {
144 | counter := new(atomic.Int32)
145 | _v, _ := mm.LoadOrStore(now.Unix()-int64(now.Second()), counter)
146 | counter = _v.(*atomic.Int32)
147 | counter.Add(1)
148 | }
149 | bufDebug.WriteString(fmt.Sprintf("%s\t%d", now.Format("2006-01-02 15:04:05.000"), now.Unix()))
150 | // t.Log(now.Format("2006-01-02 15:04:05.000"), now.Unix())
151 | }()
152 | } else {
153 | if err := b.wait(); err != nil {
154 | t.Errorf("Received unexpected error:\n%+v", err)
155 | }
156 | now := time.Now()
157 | {
158 | counter := new(atomic.Int32)
159 | _v, _ := ms.LoadOrStore(now.Unix(), counter)
160 | counter = _v.(*atomic.Int32)
161 | counter.Add(1)
162 | }
163 | {
164 | counter := new(atomic.Int32)
165 | _v, _ := mm.LoadOrStore(now.Unix()-int64(now.Second()), counter)
166 | counter = _v.(*atomic.Int32)
167 | counter.Add(1)
168 | }
169 | bufDebug.WriteString(fmt.Sprintf("%s\t%d", now.Format("2006-01-02 15:04:05.000"), now.Unix()))
170 | // t.Log(now.Format("2006-01-02 15:04:05.000"), now.Unix())
171 | }
172 | }
173 |
174 | if tt.wg != nil {
175 | tt.wg.Wait()
176 | }
177 |
178 | d := time.Since(start)
179 | if d < tt.wantDuration-offsetDuration {
180 | t.Errorf("Actual min duration: %s, want: %s\nDEBUG:\n%s", d, tt.wantDuration-offsetDuration, bufDebug.String())
181 | }
182 | if d > tt.wantDuration+offsetDuration {
183 | t.Errorf("Actual max duration: %s, want: %s\nDEBUG:\n%s", d, tt.wantDuration+offsetDuration, bufDebug.String())
184 | }
185 |
186 | {
187 | keys := make([]int64, 0, int(tt.wantDuration.Seconds()))
188 | ms.Range(func(key, value any) bool {
189 | keys = append(keys, key.(int64))
190 | return true
191 | })
192 | slices.SortFunc(keys, func(a, b int64) int {
193 | return cmp.Compare(a, b)
194 | })
195 |
196 | for _, key := range keys {
197 | _v, _ := ms.Load(key)
198 | n := _v.(*atomic.Int32).Load()
199 | if int(n) > b.opts.LimiterPerSecond {
200 | t.Errorf("Actual per second: %d, want: %d\nDEBUG:\n%s", n, b.opts.LimiterPerSecond, bufDebug.String())
201 | }
202 | }
203 | }
204 | {
205 | keys := make([]int64, 0, int(tt.wantDuration.Minutes()))
206 | mm.Range(func(key, value any) bool {
207 | keys = append(keys, key.(int64))
208 | return true
209 | })
210 | slices.SortFunc(keys, func(a, b int64) int {
211 | return cmp.Compare(a, b)
212 | })
213 |
214 | for _, key := range keys {
215 | _v, _ := mm.Load(key)
216 | n := _v.(*atomic.Int32).Load()
217 | if int(n) > b.opts.LimiterPerMinute {
218 | t.Errorf("Actual per minute: %d, want: %d\nDEBUG:\n%s", n, b.opts.LimiterPerMinute, bufDebug.String())
219 | }
220 | }
221 | }
222 |
223 | })
224 |
225 | }()
226 | }
227 |
228 | wg.Wait()
229 | }
230 |
231 | func Test_bot_SendText(t *testing.T) {
232 | t.Run("full_webhook_has_secret_key", func(t *testing.T) {
233 | var (
234 | webhook = os.Getenv("webhook")
235 | secretKey = os.Getenv("secret_key")
236 | b = NewBot(webhook, &BotOptions{SecretKey: secretKey})
237 | )
238 | requireNoError(t, b.SendText("hi"))
239 | })
240 |
241 | t.Run("only_webhook_access_token_has_secret_key", func(t *testing.T) {
242 | var (
243 | webhook = os.Getenv("webhook")
244 | secretKey = os.Getenv("secret_key")
245 | b = NewBot(webhook, NewBotOptions().SetSecretKey(secretKey))
246 | )
247 | requireNoError(t, b.SendText("hi"+TextAtEveryone()))
248 | })
249 |
250 | t.Run("only_webhook_access_token_no_secret_key_at_nobody", func(t *testing.T) {
251 | var (
252 | webhook = os.Getenv("webhook")
253 | b = NewBot(webhook, nil)
254 | )
255 | requireNoError(t, b.SendText("hi"+TextAtPerson("nonexistent", "nobody")))
256 | })
257 |
258 | t.Run("only_webhook_access_token_no_secret_key_at_user_id", func(t *testing.T) {
259 | var (
260 | webhook = os.Getenv("webhook")
261 | b = NewBot(webhook, nil)
262 | userID = os.Getenv("user_id")
263 | )
264 | requireNoError(t, b.SendText("hi"+TextAtPerson(userID, "")))
265 | })
266 |
267 | t.Run("only_webhook_access_token_no_secret_key_at_open_id", func(t *testing.T) {
268 | var (
269 | webhook = os.Getenv("webhook")
270 | b = NewBot(webhook, nil)
271 | openID = os.Getenv("open_id")
272 | )
273 | requireNoError(t, b.SendText("hi"+TextAtPerson(openID, "")))
274 | })
275 | }
276 |
277 | func Test_bot_SendGroupBusinessCar(t *testing.T) {
278 | var (
279 | webhook = os.Getenv("webhook")
280 | b = NewBot(webhook, nil)
281 | chatID = os.Getenv("chat_id")
282 | )
283 | requireNoError(t, b.SendGroupBusinessCard(chatID))
284 | }
285 |
286 | func Test_bot_SendImage(t *testing.T) {
287 | var (
288 | webhook = os.Getenv("webhook")
289 | b = NewBot(webhook, nil)
290 | imgKey = os.Getenv("image_key")
291 | )
292 | requireNoError(t, b.SendImage(imgKey))
293 | }
294 |
295 | func Test_bot_SendRichText(t *testing.T) {
296 | var (
297 | webhook = os.Getenv("webhook")
298 | secretKey = os.Getenv("secret_key")
299 | b = NewBot(webhook, NewBotOptions().SetSecretKey(secretKey))
300 | userID = os.Getenv("user_id")
301 | )
302 | err := b.SendRichText(
303 | NewRichText(LanguageChinese, "🇨🇳 标题").
304 | Text("🇨🇳 文本", false).
305 | Hyperlink("超链接", "https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot?lang=zh-CN#f62e72d5").
306 | Image("img_7ea74629-9191-4176-998c-2e603c9c5e8g").
307 | Text("\n", false).
308 | AtEveryone().
309 | Text("+", false).
310 | At(userID, "").
311 | Image("img_ecffc3b9-8f14-400f-a014-05eca1a4310g"),
312 | NewRichText(LanguageEnglish, "🇬🇧🇺🇸 title").
313 | Text("🇨🇳 text", false).
314 | Hyperlink("hyper link", "https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot?lang=zh-CN#f62e72d5").
315 | Image("img_7ea74629-9191-4176-998c-2e603c9c5e8g").
316 | Text("\n", false).
317 | AtEveryone().
318 | Text("+", false).
319 | At(userID, "").
320 | Image("img_ecffc3b9-8f14-400f-a014-05eca1a4310g"),
321 | NewRichText(LanguageJapanese, "🇯🇵 タイトル").
322 | Text("🇯🇵 テキスト", false).
323 | Hyperlink("ハイパーリンク", "https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot?lang=zh-CN#f62e72d5").
324 | Image("img_7ea74629-9191-4176-998c-2e603c9c5e8g").
325 | Text("\n", false).
326 | AtEveryone().
327 | Text("+", false).
328 | At(userID, "").
329 | Image("img_ecffc3b9-8f14-400f-a014-05eca1a4310g"),
330 | NewRichText("zh_hk", "🇨🇳🇭🇰 标题").
331 | Text("🇨🇳🇭🇰 文本", false).
332 | Hyperlink("超链接", "https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot?lang=zh-CN#f62e72d5").
333 | Image("img_7ea74629-9191-4176-998c-2e603c9c5e8g").
334 | Text("\n", false).
335 | AtEveryone().
336 | Text("+", false).
337 | At(userID, "").
338 | Image("img_ecffc3b9-8f14-400f-a014-05eca1a4310g"),
339 | )
340 | requireNoError(t, err)
341 | }
342 |
343 | func Test_bot_SendCard(t *testing.T) {
344 | var (
345 | webhook = os.Getenv("webhook")
346 | secretKey = os.Getenv("secret_key")
347 | b = NewBot(webhook, NewBotOptions().SetSecretKey(secretKey))
348 | )
349 | err := b.SendCard(
350 | NewCardGlobalConfig().
351 | HeaderIcon("img_ecffc3b9-8f14-400f-a014-05eca1a4310g").
352 | HeaderTemplate(CardHeaderTemplateGreen).
353 | CardLink(
354 | "https://www.feishu.cn",
355 | "https://www.windows.com",
356 | "https://developer.apple.com",
357 | "https://developer.android.com",
358 | ),
359 | NewCard(LanguageChinese, "🇨🇳 标题").
360 | HeaderSubtitle("🇨🇳 副标题").
361 | HeaderTextTags([]CardHeaderTextTag{
362 | {Content: "标题标签", Color: CardHeaderTextTagColorCarmine},
363 | }),
364 | NewCard(LanguageEnglish, "🇬🇧🇺🇸 title").
365 | HeaderSubtitle("🇬🇧🇺🇸 subtitle").
366 | HeaderTextTags([]CardHeaderTextTag{
367 | {Content: "tagDemo", Color: CardHeaderTextTagColorCarmine},
368 | }),
369 | )
370 | requireNoError(t, err)
371 | }
372 |
373 | func Test_bot_SendCard_CardElementDiv(t *testing.T) {
374 | var (
375 | webhook = os.Getenv("webhook")
376 | secretKey = os.Getenv("secret_key")
377 | b = NewBot(webhook, NewBotOptions().SetSecretKey(secretKey))
378 | )
379 |
380 | // 文本
381 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/embedded-non-interactive-elements/text#e22d4592
382 | t.Run("text", func(t *testing.T) {
383 | err := b.SendCard(nil,
384 | NewCard(LanguageChinese, "").
385 | Elements([]CardElement{
386 | NewCardElementDiv().LarkMarkdown("**text**/~~text~~"),
387 | NewCardElementDiv().PlainText("测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本", 2),
388 | }),
389 | )
390 | requireNoError(t, err)
391 | })
392 |
393 | // 双列文本
394 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/embedded-non-interactive-elements/field#e22d4592
395 | t.Run("fields", func(t *testing.T) {
396 | err := b.SendCard(nil,
397 | NewCard(LanguageChinese, "你有一个休假申请待审批").
398 | Elements([]CardElement{
399 | NewCardElementDiv().Fields([]CardElementDivFieldText{
400 | {
401 | IsShort: true,
402 | Mode: CardElementDivTextModeLarkMarkdown,
403 | Content: "**申请人**\n王晓磊",
404 | },
405 | {
406 | IsShort: true,
407 | Mode: CardElementDivTextModeLarkMarkdown,
408 | Content: "**休假类型:**\n年假",
409 | },
410 | {IsShort: false, Mode: CardElementDivTextModeLarkMarkdown, Content: ""},
411 | {IsShort: false, Mode: CardElementDivTextModeLarkMarkdown, Content: "**时间:**\n2020-4-8 至 2020-4-10(共3天)"},
412 | {IsShort: false, Mode: CardElementDivTextModeLarkMarkdown, Content: ""},
413 | {
414 | IsShort: true,
415 | Mode: CardElementDivTextModeLarkMarkdown,
416 | Content: "**备注**\n因家中有急事,需往返老家,故请假",
417 | },
418 | }),
419 | }),
420 | )
421 | requireNoError(t, err)
422 | })
423 |
424 | // 附加图片元素
425 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/embedded-non-interactive-elements/image
426 | t.Run("extra_image", func(t *testing.T) {
427 | err := b.SendCard(nil,
428 | NewCard(LanguageChinese, "").
429 | Elements([]CardElement{
430 | NewCardElementDiv().
431 | PlainText("image element 1", 0).
432 | ExtraImage("img_7ea74629-9191-4176-998c-2e603c9c5e8g", true, "hover图片后的tips文案"),
433 | NewCardElementHorizontalRule(),
434 | NewCardElementDiv().
435 | PlainText("image element 2", 0).
436 | ExtraImage("img_7ea74629-9191-4176-998c-2e603c9c5e8g", true, "hover图片后的tips文案hover图片后的tips文案hover图片后的tips文案hover图片后的tips文案hover图片后的tips文案hover图片后的tips文案hover图片后的tips文案"),
437 | NewCardElementHorizontalRule(),
438 | }),
439 | )
440 | requireNoError(t, err)
441 | })
442 | }
443 |
444 | func Test_bot_SendCard_CardElementMarkdown(t *testing.T) {
445 | var (
446 | webhook = os.Getenv("webhook")
447 | secretKey = os.Getenv("secret_key")
448 | b = NewBot(webhook, NewBotOptions().SetSecretKey(secretKey))
449 | )
450 |
451 | // Markdown 组件
452 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/using-markdown-tags#7e4a981e
453 | t.Run("markdown", func(t *testing.T) {
454 | err := b.SendCard(
455 | NewCardGlobalConfig().HeaderTemplate(CardHeaderTemplateBlue),
456 | NewCard(LanguageChinese, "这是卡片标题栏").
457 | Elements([]CardElement{
458 | NewCardElementMarkdown("普通文本\n标准emoji😁😢🌞💼🏆❌✅\n*斜体*\n**粗体**\n~~删除线~~\n[文字链接](www.example.com)\n[差异化跳转]($urlVal)\n").
459 | Href(
460 | "https://www.feishu.cn",
461 | "https://www.windows.com",
462 | "https://developer.apple.com",
463 | "https://developer.android.com",
464 | ),
465 | NewCardElementHorizontalRule(),
466 | NewCardElementMarkdown("上面是一行分割线\n\n上面是一个图片标签"),
467 | }),
468 | )
469 | requireNoError(t, err)
470 | })
471 |
472 | // text 的 lark_md 模式
473 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/using-markdown-tags#aaf4b702
474 | t.Run("lark_md", func(t *testing.T) {
475 | err := b.SendCard(nil,
476 | NewCard(LanguageChinese, "").
477 | Elements([]CardElement{
478 | NewCardElementDiv().
479 | PlainText("text-lark_md", 1).
480 | Fields([]CardElementDivFieldText{
481 | {IsShort: false, Mode: CardElementDivTextModeLarkMarkdown, Content: "https://open.feishu.cn"},
482 | {IsShort: false, Mode: CardElementDivTextModeLarkMarkdown, Content: "ready\nnew line"},
483 | {IsShort: false, Mode: CardElementDivTextModeLarkMarkdown, Content: "*Italic*"},
484 | {IsShort: false, Mode: CardElementDivTextModeLarkMarkdown, Content: "**Bold**"},
485 | {IsShort: false, Mode: CardElementDivTextModeLarkMarkdown, Content: "~~delete line~~"},
486 | {IsShort: false, Mode: CardElementDivTextModeLarkMarkdown, Content: ""},
487 | }),
488 | NewCardElementHorizontalRule(),
489 | NewCardElementDiv().Fields([]CardElementDivFieldText{
490 | {Mode: CardElementDivTextModeLarkMarkdown, Content: md.AtPerson("abcd", "nobody")},
491 | {Mode: CardElementDivTextModeLarkMarkdown, Content: md.AtEveryone()},
492 | {Mode: CardElementDivTextModeLarkMarkdown, Content: md.Hyperlink("https://open.feishu.cn")},
493 | {Mode: CardElementDivTextModeLarkMarkdown, Content: md.TextLink("开放平台", "https://open.feishu.cn")},
494 | {Mode: CardElementDivTextModeLarkMarkdown, Content: md.HorizontalRule()},
495 | {Mode: CardElementDivTextModeLarkMarkdown, Content: md.FeiShuEmoji("DONE")},
496 | {Mode: CardElementDivTextModeLarkMarkdown, Content: md.GreenText("绿色文本")},
497 | {Mode: CardElementDivTextModeLarkMarkdown, Content: md.RedText("红色文本")},
498 | {Mode: CardElementDivTextModeLarkMarkdown, Content: md.GreyText("灰色文本")},
499 | {Mode: CardElementDivTextModeLarkMarkdown, Content: "默认的白底黑字样式"},
500 | {Mode: CardElementDivTextModeLarkMarkdown, Content: md.TextTag(md.TextTagColorRed, "红色标签")},
501 | }),
502 | }),
503 | )
504 | requireNoError(t, err)
505 | })
506 | }
507 |
508 | func Test_bot_SendCard_CardElementImage(t *testing.T) {
509 | var (
510 | webhook = os.Getenv("webhook")
511 | secretKey = os.Getenv("secret_key")
512 | b = NewBot(webhook, NewBotOptions().SetSecretKey(secretKey))
513 | )
514 |
515 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/image-module#e22d4592
516 | err := b.SendCard(nil,
517 | NewCard(LanguageChinese, "").
518 | Elements([]CardElement{
519 | NewCardElementImage("img_7ea74629-9191-4176-998c-2e603c9c5e8g", "Hover图片后的tips提示,不需要可以传空").
520 | // TitleWithPlainText("Block-img").
521 | TitleWithLarkMarkdown("*Block*-img").
522 | Mode(CardElementImageModeFitHorizontal).
523 | CompactWidth(false),
524 | }),
525 | )
526 | requireNoError(t, err)
527 | }
528 |
529 | func Test_bot_SendCard_CardElementNote(t *testing.T) {
530 | var (
531 | webhook = os.Getenv("webhook")
532 | secretKey = os.Getenv("secret_key")
533 | b = NewBot(webhook, NewBotOptions().SetSecretKey(secretKey))
534 | )
535 |
536 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/notes-module#3827dadd
537 | err := b.SendCard(nil,
538 | NewCard(LanguageChinese, "").
539 | Elements([]CardElement{
540 | NewCardElementNote().
541 | AddElementWithImage("img_v2_041b28e3-5680-48c2-9af2-497ace79333g", true, "这是备注图片1").
542 | AddElementWithPlainText("备注信息1").
543 | AddElementWithImage("img_v2_041b28e3-5680-48c2-9af2-497ace79333g", false, "这是备注图片").
544 | AddElementWithPlainText("备注信息2").
545 | AddElementWithImage("img_v2_041b28e3-5680-48c2-9af2-497ace79333g", false, "alt_3").
546 | AddElementWithLarkMarkdown("*备注*~~信息~~" + md.RedText("3")),
547 | }),
548 | )
549 | requireNoError(t, err)
550 | }
551 |
552 | func Test_bot_SendCard_CardElementAction(t *testing.T) {
553 | var (
554 | webhook = os.Getenv("webhook")
555 | secretKey = os.Getenv("secret_key")
556 | b = NewBot(webhook, NewBotOptions().SetSecretKey(secretKey))
557 | )
558 |
559 | // https://open.feishu.cn/document/common-capabilities/message-card/add-card-interaction/interactive-components/button#1a79927c
560 |
561 | t.Run("button_url", func(t *testing.T) {
562 | err := b.SendCard(nil,
563 | NewCard(LanguageChinese, "").
564 | Elements([]CardElement{
565 | NewCardElementAction().Actions([]CardElementActionComponent{
566 | NewCardElementActionButton(CardElementDivTextModePlainText, "主按钮").
567 | Type(CardElementActionButtonTypePrimary).
568 | URL("https://open.feishu.cn/document"),
569 | }),
570 | }),
571 | )
572 | requireNoError(t, err)
573 | })
574 |
575 | t.Run("button_multi_url", func(t *testing.T) {
576 | err := b.SendCard(nil,
577 | NewCard(LanguageChinese, "").
578 | Elements([]CardElement{
579 | NewCardElementAction().Actions([]CardElementActionComponent{
580 | NewCardElementActionButton(CardElementDivTextModePlainText, "主按钮").
581 | Type(CardElementActionButtonTypePrimary).
582 | MultiURL(
583 | "https://www.baidu.com",
584 | "https://www.windows.com",
585 | "lark://msgcard/unsupported_action",
586 | "https://developer.android.com",
587 | ),
588 | }),
589 | }),
590 | )
591 | requireNoError(t, err)
592 | })
593 |
594 | // https://open.feishu.cn/document/common-capabilities/message-card/add-card-interaction/interactive-components/overflow#1a79927c
595 | t.Run("overflow", func(t *testing.T) {
596 | err := b.SendCard(nil,
597 | NewCard(LanguageChinese, "").
598 | Elements([]CardElement{
599 | NewCardElementAction().Actions([]CardElementActionComponent{
600 | NewCardElementActionButton(CardElementDivTextModePlainText, "主按钮").
601 | Type(CardElementActionButtonTypePrimary).
602 | URL("https://open.feishu.cn/document"),
603 | NewCardElementActionOverflow().
604 | AddOptionWithMultiURL(
605 | "Option-1",
606 | "https://www.baidu.com",
607 | "https://www.windows.com",
608 | "https://developer.apple.com",
609 | "https://developer.android.com",
610 | ).
611 | AddOptionWithURL("baidu", "https://www.baidu.com").
612 | AddOptionWithURL("开发文档", "https://open.feishu.cn/document/home/index").Confirm("Confirmation", "Content"),
613 | }),
614 | }),
615 | )
616 | requireNoError(t, err)
617 | })
618 |
619 | // https://open.feishu.cn/document/common-capabilities/message-card/add-card-interaction/interactive-components/button?lang=zh-CN#e22d4592
620 | t.Run("example", func(t *testing.T) {
621 | defaultURL := "https://open.feishu.cn/document/common-capabilities/message-card/add-card-interaction/interactive-components/button?lang=zh-CN#e22d4592"
622 | err := b.SendCard(nil,
623 | NewCard(LanguageChinese, "").
624 | Elements([]CardElement{
625 | NewCardElementDiv().
626 | LarkMarkdown("Button element").
627 | // ExtraAction(
628 | // NewCardElementActionButton(CardElementDivTextModeLarkMarkdown, "Secondary confirmation").
629 | // Type(CardElementActionButtonTypeDefault).
630 | // URL(defaultURL).
631 | // Confirm("Confirmation", "Content"),
632 | // ).
633 | ExtraAction(
634 | NewCardElementActionOverflow().
635 | AddOptionWithMultiURL(
636 | "Option-1",
637 | "https://www.baidu.com",
638 | "https://www.windows.com",
639 | "https://developer.apple.com",
640 | "https://developer.android.com",
641 | ).
642 | AddOptionWithURL("baidu", "https://www.baidu.com").
643 | AddOptionWithURL("开发文档", "https://open.feishu.cn/document/home/index").Confirm("Confirmation", "Content"),
644 | ),
645 | NewCardElementHorizontalRule(),
646 | NewCardElementAction().Actions([]CardElementActionComponent{
647 | NewCardElementActionButton(CardElementDivTextModeLarkMarkdown, "default style").Type(CardElementActionButtonTypeDefault).URL(defaultURL),
648 | NewCardElementActionButton(CardElementDivTextModeLarkMarkdown, "primary style").Type(CardElementActionButtonTypePrimary).URL(defaultURL),
649 | NewCardElementActionButton(CardElementDivTextModeLarkMarkdown, "danger style").Type(CardElementActionButtonTypeDanger).URL(defaultURL),
650 | NewCardElementActionButton(CardElementDivTextModeLarkMarkdown, "target url").Type(CardElementActionButtonTypeDefault).URL("https://www.baidu.com"),
651 | NewCardElementActionButton(CardElementDivTextModeLarkMarkdown, "multi url").
652 | Type(CardElementActionButtonTypePrimary).
653 | MultiURL(
654 | "https://www.baidu.com",
655 | "https://www.windows.com",
656 | "https://developer.apple.com",
657 | "https://developer.android.com",
658 | ),
659 | }),
660 | NewCardElementNote().AddElementWithPlainText("hello World"),
661 | }),
662 | )
663 | requireNoError(t, err)
664 | })
665 | }
666 |
667 | func Test_bot_SendCard_CardElementColumnSet(t *testing.T) {
668 | var (
669 | webhook = os.Getenv("webhook")
670 | secretKey = os.Getenv("secret_key")
671 | b = NewBot(webhook, NewBotOptions().SetSecretKey(secretKey))
672 | )
673 |
674 | t.Run("columns_elements", func(t *testing.T) {
675 | err := b.SendCard(nil,
676 | NewCard(LanguageChinese, "多列布局").Elements([]CardElement{
677 | NewCardElementMarkdown("*斜体*\n**粗体**\n~~删除线~~" + md.LineBreak()),
678 | NewCardElementColumnSet().
679 | BackgroundStyle(CardElementColumnSetBackgroundStyleGrey).
680 | FlexMode(CardElementColumnSetFlexModeFlow).
681 | Columns([]*CardElementColumnSetColumn{
682 | NewCardElementColumnSetColumn().
683 | Width(CardElementColumnSetColumnWidthWeighted).
684 | Weight(1).
685 | Elements([]CardElement{
686 | NewCardElementMarkdown("**Markdown** *组件*"),
687 | NewCardElementDiv().LarkMarkdown("**再加一个**/~~text~~"),
688 | }),
689 | NewCardElementColumnSetColumn().
690 | Width(CardElementColumnSetColumnWidthWeighted).
691 | Weight(1).
692 | Elements([]CardElement{NewCardElementDiv().LarkMarkdown("**text**/~~text~~")}),
693 | NewCardElementColumnSetColumn().
694 | Width(CardElementColumnSetColumnWidthWeighted).
695 | Weight(1).
696 | Elements([]CardElement{NewCardElementDiv().PlainText("测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本", 2)}),
697 | NewCardElementColumnSetColumn().
698 | Width(CardElementColumnSetColumnWidthWeighted).
699 | Weight(1).
700 | Elements([]CardElement{NewCardElementDiv().PlainText("测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本测试文本", 0)}),
701 | NewCardElementColumnSetColumn().
702 | Width(CardElementColumnSetColumnWidthWeighted).
703 | Weight(1).
704 | Elements([]CardElement{
705 | NewCardElementImage("img_7ea74629-9191-4176-998c-2e603c9c5e8g", "Hover图片后的tips提示,不需要可以传空").
706 | // TitleWithPlainText("Block-img").
707 | TitleWithLarkMarkdown("*Block*-img").
708 | Mode(CardElementImageModeFitHorizontal).
709 | CompactWidth(false),
710 | }),
711 | NewCardElementColumnSetColumn().
712 | Width(CardElementColumnSetColumnWidthWeighted).
713 | Weight(1).
714 | Elements([]CardElement{NewCardElementHorizontalRule()}),
715 | NewCardElementColumnSetColumn().
716 | Width(CardElementColumnSetColumnWidthWeighted).
717 | Weight(1).
718 | Elements([]CardElement{
719 | NewCardElementNote().
720 | AddElementWithImage("img_v2_041b28e3-5680-48c2-9af2-497ace79333g", true, "这是备注图片1").
721 | AddElementWithPlainText("备注信息1").
722 | AddElementWithImage("img_v2_041b28e3-5680-48c2-9af2-497ace79333g", false, "这是备注图片").
723 | AddElementWithPlainText("备注信息2").
724 | AddElementWithImage("img_v2_041b28e3-5680-48c2-9af2-497ace79333g", false, "alt_3").
725 | AddElementWithLarkMarkdown("*备注*~~信息~~" + md.RedText("3")),
726 | }),
727 | }),
728 | NewCardElementColumnSet().
729 | BackgroundStyle(CardElementColumnSetBackgroundStyleGrey).
730 | FlexMode(CardElementColumnSetFlexModeFlow).
731 | Columns([]*CardElementColumnSetColumn{
732 | NewCardElementColumnSetColumn().Width(CardElementColumnSetColumnWidthWeighted).Weight(1).
733 | Elements([]CardElement{
734 | NewCardElementDiv().
735 | LarkMarkdown("ISV产品接入及企业自主开发,更好地对接现有系统,满足不同组织的需求。").
736 | ExtraAction(
737 | NewCardElementActionOverflow().
738 | AddOptionWithURL("打开飞书应用目录", "https://app.feishu.cn").
739 | AddOptionWithURL("打开飞书开发文档", "https://open.feishu.cn").
740 | AddOptionWithURL("打开飞书官网", "https://www.feishu.cn"),
741 | ),
742 | }),
743 | }),
744 | }),
745 | )
746 | requireNoError(t, err)
747 | })
748 |
749 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/column-set?lang=zh-CN#5a267d0d
750 | t.Run("example_1", func(t *testing.T) {
751 | err := b.SendCard(nil,
752 | NewCard(LanguageChinese, "").Elements([]CardElement{
753 | NewCardElementMarkdown("**个人审批效率总览**" + md.LineBreak()),
754 | NewCardElementColumnSet().
755 | FlexMode(CardElementColumnSetFlexModeBisect).
756 | BackgroundStyle(CardElementColumnSetBackgroundStyleGrey).
757 | HorizontalSpacing(CardElementColumnSetHorizontalSpacingDefault).
758 | Columns([]*CardElementColumnSetColumn{
759 | NewCardElementColumnSetColumn().
760 | Width(CardElementColumnSetColumnWidthWeighted).
761 | Weight(1).
762 | Elements([]CardElement{NewCardElementMarkdown("已审批单量\n**29单**\n" + md.GreenText("领先团队59%")).TextAlign(CardElementMarkdownTextAlignCenter)}),
763 | NewCardElementColumnSetColumn().
764 | Width(CardElementColumnSetColumnWidthWeighted).
765 | Weight(1).
766 | Elements([]CardElement{NewCardElementMarkdown("平均审批耗时\n**0.9小时**\n" + md.GreenText("领先团队100%")).TextAlign(CardElementMarkdownTextAlignCenter)}),
767 | NewCardElementColumnSetColumn().
768 | Width(CardElementColumnSetColumnWidthWeighted).
769 | Weight(1).
770 | Elements([]CardElement{NewCardElementMarkdown("待批率\n**25%**\n" + md.RedText("落后团队10%")).TextAlign(CardElementMarkdownTextAlignCenter)}),
771 | }),
772 | NewCardElementMarkdown("**团队审批效率参考:**"),
773 | NewCardElementColumnSet().
774 | // FlexMode(CardElementColumnSetFlexModeNone).
775 | BackgroundStyle(CardElementColumnSetBackgroundStyleGrey).
776 | Columns([]*CardElementColumnSetColumn{
777 | NewCardElementColumnSetColumn().
778 | Width(CardElementColumnSetColumnWidthWeighted).
779 | Weight(1).
780 | VerticalAlign(CardElementColumnSetColumnVerticalAlignTop).
781 | Elements([]CardElement{NewCardElementMarkdown("**审批人**").TextAlign(CardElementMarkdownTextAlignCenter)}),
782 | NewCardElementColumnSetColumn().
783 | Width(CardElementColumnSetColumnWidthWeighted).
784 | Weight(1).
785 | VerticalAlign(CardElementColumnSetColumnVerticalAlignTop).
786 | Elements([]CardElement{NewCardElementMarkdown("**审批时长**").TextAlign(CardElementMarkdownTextAlignCenter)}),
787 | NewCardElementColumnSetColumn().
788 | Width(CardElementColumnSetColumnWidthWeighted).
789 | Weight(1).
790 | VerticalAlign(CardElementColumnSetColumnVerticalAlignTop).
791 | Elements([]CardElement{NewCardElementMarkdown("**对比上周变化**").TextAlign(CardElementMarkdownTextAlignCenter)}),
792 | }),
793 | NewCardElementColumnSet().Columns([]*CardElementColumnSetColumn{
794 | NewCardElementColumnSetColumn().
795 | Width(CardElementColumnSetColumnWidthWeighted).
796 | Weight(1).
797 | VerticalAlign(CardElementColumnSetColumnVerticalAlignTop).
798 | Elements([]CardElement{NewCardElementMarkdown("王大明").TextAlign(CardElementMarkdownTextAlignCenter)}),
799 | NewCardElementColumnSetColumn().
800 | Width(CardElementColumnSetColumnWidthWeighted).
801 | Weight(1).
802 | VerticalAlign(CardElementColumnSetColumnVerticalAlignTop).
803 | Elements([]CardElement{NewCardElementMarkdown("小于1小时").TextAlign(CardElementMarkdownTextAlignCenter)}),
804 | NewCardElementColumnSetColumn().
805 | Width(CardElementColumnSetColumnWidthWeighted).
806 | Weight(1).
807 | VerticalAlign(CardElementColumnSetColumnVerticalAlignTop).
808 | Elements([]CardElement{NewCardElementMarkdown(md.GreenText("⬇️12%")).TextAlign(CardElementMarkdownTextAlignCenter)}),
809 | }),
810 | NewCardElementColumnSet().Columns([]*CardElementColumnSetColumn{
811 | NewCardElementColumnSetColumn().
812 | Width(CardElementColumnSetColumnWidthWeighted).
813 | Weight(1).
814 | VerticalAlign(CardElementColumnSetColumnVerticalAlignTop).
815 | Elements([]CardElement{NewCardElementMarkdown("张军").TextAlign(CardElementMarkdownTextAlignCenter)}),
816 | NewCardElementColumnSetColumn().
817 | Width(CardElementColumnSetColumnWidthWeighted).
818 | Weight(1).
819 | VerticalAlign(CardElementColumnSetColumnVerticalAlignTop).
820 | Elements([]CardElement{NewCardElementMarkdown("2小时").TextAlign(CardElementMarkdownTextAlignCenter)}),
821 | NewCardElementColumnSetColumn().
822 | Width(CardElementColumnSetColumnWidthWeighted).
823 | Weight(1).
824 | VerticalAlign(CardElementColumnSetColumnVerticalAlignTop).
825 | Elements([]CardElement{NewCardElementMarkdown(md.RedText("⬆️5%")).TextAlign(CardElementMarkdownTextAlignCenter)}),
826 | }),
827 | }),
828 | )
829 | requireNoError(t, err)
830 | })
831 |
832 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/column-set?lang=zh-CN#8efc1337
833 | t.Run("example_2", func(t *testing.T) {
834 | err := b.SendCard(
835 | NewCardGlobalConfig().HeaderTemplate(CardHeaderTemplateGreen),
836 | NewCard(LanguageChinese, "🏨 酒店申请已通过,请选择房型").Elements([]CardElement{
837 | NewCardElementMarkdown("**入住酒店:**[杭州xxxx酒店](https://open.feishu.cn/)\n📍 浙江省杭州市西湖区"),
838 | NewCardElementHorizontalRule(),
839 | NewCardElementColumnSet().
840 | FlexMode(CardElementColumnSetFlexModeNone).
841 | BackgroundStyle(CardElementColumnSetBackgroundStyleDefault).
842 | HorizontalSpacing(CardElementColumnSetHorizontalSpacingDefault).
843 | ActionMultiURL(
844 | "https://open.feishu.cn",
845 | "https://www.windows.com",
846 | "https://developer.apple.com",
847 | "https://developer.android.com",
848 | ).
849 | Columns([]*CardElementColumnSetColumn{
850 | NewCardElementColumnSetColumn().
851 | Width(CardElementColumnSetColumnWidthWeighted).
852 | Weight(1).
853 | VerticalAlign(CardElementColumnSetColumnVerticalAlignCenter).
854 | Elements([]CardElement{NewCardElementImage("img_v2_120b03c8-27e3-456f-89c0-90ede1aa59ag", "").Mode(CardElementImageModeFitHorizontal)}),
855 | NewCardElementColumnSetColumn().
856 | Width(CardElementColumnSetColumnWidthWeighted).
857 | Weight(3).
858 | Elements([]CardElement{NewCardElementMarkdown("**高级双床房**\n双早|40-47㎡|有窗户|双床\n¥699 起").TextAlign(CardElementMarkdownTextAlignLeft)}),
859 | }),
860 | NewCardElementHorizontalRule(),
861 | NewCardElementColumnSet().
862 | FlexMode(CardElementColumnSetFlexModeNone).
863 | BackgroundStyle(CardElementColumnSetBackgroundStyleDefault).
864 | HorizontalSpacing(CardElementColumnSetHorizontalSpacingDefault).
865 | ActionMultiURL(
866 | "https://open.feishu.cn",
867 | "https://www.windows.com",
868 | "https://developer.apple.com",
869 | "https://developer.android.com",
870 | ).
871 | Columns([]*CardElementColumnSetColumn{
872 | NewCardElementColumnSetColumn().
873 | Width(CardElementColumnSetColumnWidthWeighted).
874 | Weight(1).
875 | VerticalAlign(CardElementColumnSetColumnVerticalAlignCenter).
876 | Elements([]CardElement{NewCardElementImage("img_v2_120b03c8-27e3-456f-89c0-90ede1aa59ag", "").Mode(CardElementImageModeFitHorizontal)}),
877 | NewCardElementColumnSetColumn().
878 | Width(CardElementColumnSetColumnWidthWeighted).
879 | Weight(3).
880 | Elements([]CardElement{NewCardElementMarkdown("**精品大床房**\n双早|40-47㎡|有窗户|大床\n¥666 起").TextAlign(CardElementMarkdownTextAlignLeft)}),
881 | }),
882 | }),
883 | )
884 | requireNoError(t, err)
885 | })
886 | }
887 |
888 | func Test_bot_SendCardViaTemplate(t *testing.T) {
889 | var (
890 | webhook = os.Getenv("webhook")
891 | secretKey = os.Getenv("secret_key")
892 | b = NewBot(webhook, NewBotOptions().SetSecretKey(secretKey))
893 | templateID = os.Getenv("template_id")
894 | )
895 |
896 | type (
897 | tplVarsGroupTable struct {
898 | Person string `json:"person"`
899 | Time string `json:"time"`
900 | WeekRate string `json:"week_rate"`
901 | }
902 |
903 | tplVars struct {
904 | TotalCount string `json:"total_count"`
905 | TotalPercent string `json:"total_percent"`
906 | Hours string `json:"hours"`
907 | HoursPercent string `json:"hours_percent"`
908 | Pending string `json:"pending"`
909 | PendingRate string `json:"pending_rate"`
910 | GroupTable []tplVarsGroupTable `json:"group_table"`
911 | }
912 | )
913 |
914 | variables := tplVars{
915 | TotalCount: "29",
916 | TotalPercent: "领先团队59%",
917 | Hours: "0.9",
918 | HoursPercent: "领先团队100%",
919 | Pending: "25%",
920 | PendingRate: "落后团队10%",
921 | GroupTable: []tplVarsGroupTable{
922 | {Person: "王大明", Time: "小于1小时", WeekRate: "↓12%"},
923 | {Person: "张军", Time: "2小时", WeekRate: "↑5%"},
924 | {Person: "李小方", Time: "3小时", WeekRate: "↓25%"},
925 | },
926 | }
927 |
928 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/column-set?lang=zh-CN#5a267d0d
929 | err := b.SendCardViaTemplate(templateID, variables)
930 | requireNoError(t, err)
931 | }
932 |
933 | func Test_bot_Hook(t *testing.T) {
934 | t.Run("case_1", func(t *testing.T) {
935 | fn := func(body *MessageBody) error {
936 | return fmt.Errorf("surprise: %s", body.Content.Text)
937 | }
938 |
939 | b := NewBot("tmp", NewBotOptions().SetHookAfterMessageApply(fn))
940 | err := b.SendText("hi")
941 | if err.Error() != "hook(AfterMessageApply): surprise: hi" {
942 | t.FailNow()
943 | }
944 | })
945 | }
946 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/electricbubble/feishu-bot-api/v2
2 |
3 | go 1.21.0
4 |
5 | require (
6 | github.com/electricbubble/xhttpclient v0.5.1
7 | golang.org/x/time v0.5.0
8 | )
9 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/electricbubble/xhttpclient v0.5.1 h1:yeJDY4QjbIn2mKF/twDw8Wjmo5iMd01acyqa45t2JQc=
2 | github.com/electricbubble/xhttpclient v0.5.1/go.mod h1:6fXs0QIRAGvvE2hs9aeCA4GEqNrdCYFmQ2gQy4tJZLg=
3 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
4 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
5 |
--------------------------------------------------------------------------------
/md/md.go:
--------------------------------------------------------------------------------
1 | package md
2 |
3 | import "fmt"
4 |
5 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/using-markdown-tags?lang=zh-CN#abc9b025
6 |
7 | func LineBreak() string {
8 | return "\n"
9 | }
10 |
11 | func Italic(s string) string {
12 | return fmt.Sprintf("*%s*", s)
13 | }
14 |
15 | func Bold(s string) string {
16 | return fmt.Sprintf("**%s**", s)
17 | }
18 |
19 | func Strikethrough(s string) string {
20 | return fmt.Sprintf("~~%s~~", s)
21 | }
22 |
23 | // AtPerson @指定人
24 | //
25 | // 自定义机器人仅支持使用 open_id、user_id @指定人
26 | //
27 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/using-markdown-tags?lang=zh-CN#abc9b025
28 | func AtPerson(id, name string) string {
29 | return fmt.Sprintf("%s", id, name)
30 | }
31 |
32 | func AtEveryone() string {
33 | return ""
34 | }
35 |
36 | func Hyperlink(s string) string {
37 | return fmt.Sprintf("", s)
38 | }
39 |
40 | func TextLink(text, link string) string {
41 | return fmt.Sprintf("[%s](%s)", text, link)
42 | }
43 |
44 | // Image
45 | // - 仅支持 Markdown 组件
46 | // - 不支持在 text 元素的 lark_md 类型中使用
47 | func Image(imgKey, hoverText string) string {
48 | return fmt.Sprintf("", hoverText, imgKey)
49 | }
50 |
51 | func HorizontalRule() string {
52 | return "\n ---\n"
53 | }
54 |
55 | // FeiShuEmoji 飞书表情
56 | //
57 | // https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce
58 | func FeiShuEmoji(emojiKey string) string {
59 | return fmt.Sprintf(":%s:", emojiKey)
60 | }
61 |
62 | func GreenText(s string) string {
63 | return fmt.Sprintf("%s", s)
64 | }
65 |
66 | func RedText(s string) string {
67 | return fmt.Sprintf("%s", s)
68 | }
69 |
70 | func GreyText(s string) string {
71 | return fmt.Sprintf("%s", s)
72 | }
73 |
74 | type TextTagColor string
75 |
76 | const (
77 | TextTagColorNeutral TextTagColor = "neutral"
78 | TextTagColorBlue TextTagColor = "blue"
79 | TextTagColorTurquoise TextTagColor = "turquoise"
80 | TextTagColorLime TextTagColor = "lime"
81 | TextTagColorOrange TextTagColor = "orange"
82 | TextTagColorViolet TextTagColor = "violet"
83 | TextTagColorIndigo TextTagColor = "indigo"
84 | TextTagColorWathet TextTagColor = "wathet"
85 | TextTagColorGreen TextTagColor = "green"
86 | TextTagColorYellow TextTagColor = "yellow"
87 | TextTagColorRed TextTagColor = "red"
88 | TextTagColorPurple TextTagColor = "purple"
89 | TextTagColorCarmine TextTagColor = "carmine"
90 | )
91 |
92 | // TextTag 标签
93 | //
94 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/using-markdown-tags?lang=zh-CN#abc9b025
95 | func TextTag(color TextTagColor, s string) string {
96 | return fmt.Sprintf("%s", color, s)
97 | }
98 |
--------------------------------------------------------------------------------
/msg_card.go:
--------------------------------------------------------------------------------
1 | package feishu_bot_api
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "slices"
8 | )
9 |
10 | var _ Message = (*cardMessage)(nil)
11 |
12 | type cardMessage struct {
13 | globalConf *CardGlobalConfig
14 |
15 | builders []*CardBuilder
16 | }
17 |
18 | func (m cardMessage) Apply(body *MessageBody) error {
19 | card := &MessageBodyCard{
20 | Header: nil,
21 | Elements: nil,
22 | I18nElements: nil,
23 | Config: nil,
24 | CardLink: nil,
25 | }
26 |
27 | rawHeader, err := json.Marshal(m.buildHeader())
28 | if err != nil {
29 | return fmt.Errorf("card message: marshal header: %w", err)
30 | }
31 | card.Header = (*json.RawMessage)(&rawHeader)
32 |
33 | rawI18nElements, err := m.marshalI18nElements()
34 | if err != nil {
35 | return fmt.Errorf("card message: marshal i18n_elements: %w", err)
36 | }
37 | card.I18nElements = &rawI18nElements
38 |
39 | if m.globalConf != nil && m.globalConf.config != nil {
40 | raw, err := json.Marshal(m.globalConf.config)
41 | if err != nil {
42 | return fmt.Errorf("card message: marshal config: %w", err)
43 | }
44 | card.Config = (*json.RawMessage)(&raw)
45 | }
46 |
47 | if m.globalConf != nil && m.globalConf.link != nil {
48 | raw, err := json.Marshal(m.globalConf.link)
49 | if err != nil {
50 | return fmt.Errorf("card message: marshal card_link: %w", err)
51 | }
52 | card.CardLink = (*json.RawMessage)(&raw)
53 | }
54 |
55 | rawCard, err := json.Marshal(card)
56 | if err != nil {
57 | return fmt.Errorf("card message: marshal: %w", err)
58 | }
59 |
60 | body.MsgType = "interactive"
61 | body.Card = (*json.RawMessage)(&rawCard)
62 | return nil
63 | }
64 |
65 | func (m cardMessage) buildHeader() cardHeader {
66 | nnBuilders := make([]*CardBuilder, 0, len(m.builders))
67 | for i := range m.builders {
68 | if m.builders[i] == nil {
69 | continue
70 | }
71 | nnBuilders = append(nnBuilders, m.builders[i])
72 | }
73 |
74 | ret := cardHeader{
75 | Title: cardHeaderTitle{
76 | Tag: "plain_text",
77 | I18n: make(cardHeaderI18nTexts, len(nnBuilders)),
78 | },
79 | Subtitle: nil,
80 | Icon: nil,
81 | Template: "",
82 | I18nTextTagList: nil,
83 | }
84 |
85 | for i := range nnBuilders {
86 | ret.Title.I18n[i] = cardHeaderI18nText{
87 | language: nnBuilders[i].language,
88 | text: nnBuilders[i].headerTitleContent,
89 | }
90 | }
91 |
92 | for i := range nnBuilders {
93 | b := nnBuilders[i]
94 | if b.headerSubtitleContent != "" {
95 | ret.Subtitle = &cardHeaderTitle{
96 | Tag: "plain_text",
97 | I18n: make(cardHeaderI18nTexts, len(nnBuilders)),
98 | }
99 | for i := range ret.Subtitle.I18n {
100 | ret.Subtitle.I18n[i] = cardHeaderI18nText{
101 | language: nnBuilders[i].language,
102 | text: nnBuilders[i].headerSubtitleContent,
103 | }
104 | }
105 | break
106 | }
107 | }
108 |
109 | if m.globalConf != nil && m.globalConf.headerIcon != nil {
110 | ret.Icon = m.globalConf.headerIcon
111 | }
112 |
113 | if m.globalConf != nil && m.globalConf.headerTemplate != "" {
114 | ret.Template = m.globalConf.headerTemplate
115 | }
116 |
117 | i18nTextTagList := make(cardHeaderI18nTextTags, 0, len(nnBuilders))
118 | for i := range nnBuilders {
119 | b := nnBuilders[i]
120 | if b.headerI18nTextTags != nil {
121 | i18nTextTagList = append(i18nTextTagList, *b.headerI18nTextTags...)
122 | }
123 | }
124 | if len(i18nTextTagList) > 0 {
125 | ret.I18nTextTagList = &i18nTextTagList
126 | }
127 |
128 | return ret
129 | }
130 |
131 | func (m cardMessage) marshalI18nElements() (json.RawMessage, error) {
132 | nnBuilders := make([]*CardBuilder, 0, len(m.builders))
133 | for i := range m.builders {
134 | if m.builders[i] == nil {
135 | continue
136 | }
137 | nnBuilders = append(nnBuilders, m.builders[i])
138 | }
139 |
140 | if len(nnBuilders) == 0 {
141 | return []byte("null"), nil
142 | }
143 |
144 | var (
145 | languages = make([]Language, 0, len(nnBuilders))
146 | esValues = make([][]any, 0, len(nnBuilders))
147 | )
148 | for i := range nnBuilders {
149 | b := nnBuilders[i]
150 | if slices.Contains(languages, b.language) {
151 | continue
152 | }
153 | languages = append(languages, b.language)
154 | esValues = append(esValues, b.elements)
155 | }
156 |
157 | var buf bytes.Buffer
158 | buf.Grow(len(languages) * 48)
159 |
160 | buf.WriteString("{")
161 |
162 | commas := len(languages)
163 | for i := range languages {
164 | commas--
165 |
166 | language := string(languages[i])
167 | es := esValues[i]
168 |
169 | bs, err := json.Marshal(es)
170 | if err != nil {
171 | return nil, fmt.Errorf("%s: %w", language, err)
172 | }
173 |
174 | buf.WriteString(fmt.Sprintf(`"%s":%s`, _quoteEscaper.Replace(language), bs))
175 |
176 | if commas > 0 {
177 | buf.WriteString(",")
178 | }
179 | }
180 |
181 | buf.WriteString("}")
182 |
183 | return buf.Bytes(), nil
184 | }
185 |
186 | // --------------------------------------------------------------------------------
187 |
188 | type (
189 | CardGlobalConfig struct {
190 | headerIcon *cardHeaderIcon
191 | headerTemplate CardHeaderTemplate
192 | config *cardConfig
193 | link *cardLink
194 | }
195 |
196 | CardBuilder struct {
197 | language Language
198 |
199 | headerTitleContent string
200 | headerSubtitleContent string
201 | headerI18nTextTags *cardHeaderI18nTextTags
202 |
203 | elements []any
204 | }
205 |
206 | CardHeaderTextTag struct {
207 | Content string
208 | Color CardHeaderTextTagColor
209 | }
210 | )
211 |
212 | func NewCardGlobalConfig() *CardGlobalConfig {
213 | return &CardGlobalConfig{}
214 | }
215 |
216 | func NewCard(language Language, title string) *CardBuilder {
217 | return &CardBuilder{
218 | language: language,
219 | headerTitleContent: title,
220 |
221 | elements: make([]any, 0, 4),
222 | }
223 | }
224 |
225 | // HeaderIcon 标题的前缀图标
226 | //
227 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/card-header#3827dadd
228 | func (gConf *CardGlobalConfig) HeaderIcon(imgKey string) *CardGlobalConfig {
229 | gConf.headerIcon = &cardHeaderIcon{ImgKey: imgKey}
230 | return gConf
231 | }
232 |
233 | // HeaderTemplate 标题主题颜色
234 | func (gConf *CardGlobalConfig) HeaderTemplate(template CardHeaderTemplate) *CardGlobalConfig {
235 | gConf.headerTemplate = template
236 | return gConf
237 | }
238 |
239 | // ConfigEnableForward 是否允许转发卡片
240 | // - true:允许
241 | // - false:不允许
242 | //
243 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/card-structure/card-configuration#3827dadd
244 | func (gConf *CardGlobalConfig) ConfigEnableForward(b bool) *CardGlobalConfig {
245 | if gConf.config == nil {
246 | gConf.config = new(cardConfig)
247 | }
248 | gConf.config.EnableForward = b
249 | return gConf
250 | }
251 |
252 | // ConfigUpdateMulti 是否为共享卡片
253 | // - true:是共享卡片,更新卡片的内容对所有收到这张卡片的人员可见
254 | // - false:非共享卡片,即独享卡片,仅操作用户可见卡片的更新内容
255 | //
256 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/card-structure/card-configuration#3827dadd
257 | func (gConf *CardGlobalConfig) ConfigUpdateMulti(b bool) *CardGlobalConfig {
258 | if gConf.config == nil {
259 | gConf.config = new(cardConfig)
260 | }
261 | gConf.config.UpdateMulti = b
262 | return gConf
263 | }
264 |
265 | // CardLink 消息卡片跳转链接
266 | //
267 | // 用于指定卡片整体的点击跳转链接,可以配置默认链接,也可以分别为 PC 端、Android 端、iOS 端配置不同的跳转链接
268 | // - 如果未配置 pc、ios、android,则默认跳转至 defaultURL
269 | // - 如果配置了 pc、ios、android,则优先生效各端指定的跳转链接
270 | //
271 | // https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/component-list/common-components-and-elements#7bfe6950
272 | func (gConf *CardGlobalConfig) CardLink(defaultURL, pc, ios, android string) *CardGlobalConfig {
273 | gConf.link = &cardLink{
274 | URL: defaultURL,
275 | PC: pc,
276 | IOS: ios,
277 | Android: android,
278 | }
279 | return gConf
280 | }
281 |
282 | // ----------------------------------------
283 |
284 | // HeaderSubtitle 卡片的副标题信息
285 | //
286 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/card-header#3827dadd
287 | func (cb *CardBuilder) HeaderSubtitle(subtitle string) *CardBuilder {
288 | cb.headerSubtitleContent = subtitle
289 | return cb
290 | }
291 |
292 | // HeaderTextTags 标题的标签属性
293 | //
294 | // 最多可配置 3 个标签内容,如果配置的标签数量超过 3 个,则取前 3 个标签进行展示。
295 | // 标签展示顺序与数组顺序一致
296 | //
297 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/card-header#3827dadd
298 | func (cb *CardBuilder) HeaderTextTags(tags []CardHeaderTextTag) *CardBuilder {
299 | tts := make(cardHeaderI18nTextTags, len(tags))
300 | for i := range tags {
301 | tts[i] = cardHeaderI18nTextTag{
302 | language: cb.language,
303 | Tag: "text_tag",
304 | Text: cardHeaderComponentPlainText{
305 | Tag: "plain_text",
306 | Content: tags[i].Content,
307 | },
308 | Color: tags[i].Color,
309 | }
310 | }
311 | cb.headerI18nTextTags = &tts
312 | return cb
313 | }
314 |
315 | // Elements 卡片的正文内容
316 | func (cb *CardBuilder) Elements(elements []CardElement) *CardBuilder {
317 | for i := range elements {
318 | if elements[i] == nil {
319 | continue
320 | }
321 | cb.elements = append(cb.elements, elements[i].Entity())
322 | }
323 | return cb
324 | }
325 |
326 | // --------------------------------------------------------------------------------
327 |
328 | type (
329 | // cardConfig
330 | //
331 | // 参数说明: https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/card-structure/card-configuration#3827dadd
332 | cardConfig struct {
333 | // 是否允许转发卡片
334 | // true:允许
335 | // false:不允许
336 | EnableForward bool `json:"enable_forward"`
337 |
338 | // 是否为共享卡片
339 | // true:是共享卡片,更新卡片的内容对所有收到这张卡片的人员可见
340 | // false:非共享卡片,即独享卡片,仅操作用户可见卡片的更新内容
341 | UpdateMulti bool `json:"update_multi"`
342 | }
343 |
344 | // cardLink
345 | //
346 | // 参数说明: https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/component-list/common-components-and-elements#7bfe6950
347 | cardLink differentJumpLinks
348 |
349 | // cardHeader
350 | //
351 | // 参数说明: https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/card-header#3827dadd
352 | cardHeader struct {
353 | // 卡片的主标题信息
354 | Title cardHeaderTitle `json:"title"`
355 |
356 | // 卡片的副标题信息
357 | Subtitle *cardHeaderTitle `json:"subtitle,omitempty"`
358 |
359 | // 标题的前缀图标。一个卡片仅可配置一个标题图标
360 | Icon *cardHeaderIcon `json:"icon,omitempty"`
361 |
362 | // 标题主题颜色
363 | Template CardHeaderTemplate `json:"template,omitempty"`
364 |
365 | // 标题标签的国际化属性。
366 | //
367 | // 最多可配置 3 个标签内容,如果配置的标签数量超过 3 个,则取前 3 个标签进行展示。
368 | // 标签展示顺序与数组顺序一致
369 | I18nTextTagList *cardHeaderI18nTextTags `json:"i18n_text_tag_list,omitempty"`
370 | }
371 | cardHeaderTitle struct {
372 | // 文本标识。固定取值:plain_text
373 | Tag string `json:"tag"`
374 |
375 | // 国际化文本内容
376 | I18n cardHeaderI18nTexts `json:"i18n"`
377 | }
378 | cardHeaderIcon struct {
379 | ImgKey string `json:"img_key,omitempty"`
380 | }
381 | )
382 |
383 | // ----------------------------------------
384 |
385 | type differentJumpLinks struct {
386 | // 默认的链接地址
387 | URL string `json:"url"`
388 |
389 | // PC 端的链接地址
390 | PC string `json:"pc_url,omitempty"`
391 |
392 | // iOS 端的链接地址
393 | IOS string `json:"ios_url,omitempty"`
394 |
395 | // Android 端的链接地址
396 | Android string `json:"android_url,omitempty"`
397 | }
398 |
399 | // ----------------------------------------
400 |
401 | // CardHeaderTemplate 标题样式
402 | //
403 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/card-header#a19af820
404 | //
405 | // 样式建议: https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/card-header#88aad907
406 | type CardHeaderTemplate string
407 |
408 | const (
409 | CardHeaderTemplateBlue CardHeaderTemplate = "blue"
410 | CardHeaderTemplateWathet CardHeaderTemplate = "wathet"
411 | CardHeaderTemplateTurquoise CardHeaderTemplate = "turquoise"
412 | CardHeaderTemplateGreen CardHeaderTemplate = "green"
413 | CardHeaderTemplateYellow CardHeaderTemplate = "yellow"
414 | CardHeaderTemplateOrange CardHeaderTemplate = "orange"
415 | CardHeaderTemplateRed CardHeaderTemplate = "red"
416 | CardHeaderTemplateCarmine CardHeaderTemplate = "carmine"
417 | CardHeaderTemplateViolet CardHeaderTemplate = "violet"
418 | CardHeaderTemplatePurple CardHeaderTemplate = "purple"
419 | CardHeaderTemplateIndigo CardHeaderTemplate = "indigo"
420 | CardHeaderTemplateGrey CardHeaderTemplate = "grey"
421 | CardHeaderTemplateDefault CardHeaderTemplate = "default"
422 | )
423 |
424 | // ----------------------------------------
425 |
426 | var _ json.Marshaler = (*cardHeaderI18nTextTags)(nil)
427 |
428 | type cardHeaderI18nTextTags []cardHeaderI18nTextTag
429 |
430 | func (tts cardHeaderI18nTextTags) MarshalJSON() ([]byte, error) {
431 | if tts == nil {
432 | return []byte("null"), nil
433 | }
434 |
435 | var (
436 | languages = make([]Language, 0, len(tts))
437 | ttsValues = make([]cardHeaderI18nTextTags, 0, len(tts))
438 | )
439 | for _, tt := range tts {
440 | idx := slices.Index(languages, tt.language)
441 | if idx == -1 {
442 | languages = append(languages, tt.language)
443 | ttsValues = append(ttsValues, cardHeaderI18nTextTags{
444 | cardHeaderI18nTextTag{Tag: tt.Tag, Text: tt.Text, Color: tt.Color},
445 | })
446 | continue
447 | }
448 | tts := ttsValues[idx]
449 | tts = append(tts, cardHeaderI18nTextTag{Tag: tt.Tag, Text: tt.Text, Color: tt.Color})
450 | ttsValues[idx] = tts
451 | }
452 |
453 | var buf bytes.Buffer
454 | buf.Grow(len(languages) * 48)
455 |
456 | buf.WriteString("{")
457 |
458 | commas := len(languages)
459 | for i := range languages {
460 | commas--
461 |
462 | language := string(languages[i])
463 |
464 | buf.WriteString(fmt.Sprintf(`"%s":`, _quoteEscaper.Replace(language)))
465 |
466 | {
467 | buf.WriteString("[")
468 | commas := len(ttsValues[i])
469 | for _, tt := range ttsValues[i] {
470 | commas--
471 |
472 | bs, err := json.Marshal(tt)
473 | if err != nil {
474 | return nil, fmt.Errorf("marshal(%s): %w", language, err)
475 | }
476 | buf.Write(bs)
477 |
478 | if commas > 0 {
479 | buf.WriteString(",")
480 | }
481 | }
482 | buf.WriteString("]")
483 | }
484 |
485 | if commas > 0 {
486 | buf.WriteString(",")
487 | }
488 | }
489 |
490 | buf.WriteString("}")
491 |
492 | return buf.Bytes(), nil
493 | }
494 |
495 | type cardHeaderI18nTextTag struct {
496 | language Language
497 |
498 | // 标题标签的标识。固定取值:text_tag
499 | Tag string `json:"tag"`
500 |
501 | // 标题标签的内容。基于文本组件的 plain_text 模式定义内容
502 | Text cardHeaderComponentPlainText `json:"text"`
503 |
504 | // 标题标签的颜色,默认为蓝色(blue)
505 | Color CardHeaderTextTagColor `json:"color,omitempty"`
506 | }
507 |
508 | type cardHeaderComponentPlainText struct {
509 | // 固定取值:plain_text
510 | Tag string `json:"tag"`
511 | Content string `json:"content"`
512 | }
513 |
514 | // ----------------------------------------
515 |
516 | // CardHeaderTextTagColor 标签样式
517 | //
518 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/card-header#616d726a
519 | type CardHeaderTextTagColor string
520 |
521 | const (
522 | CardHeaderTextTagColorNeutral CardHeaderTextTagColor = "neutral"
523 | CardHeaderTextTagColorBlue CardHeaderTextTagColor = "blue"
524 | CardHeaderTextTagColorTurquoise CardHeaderTextTagColor = "turquoise"
525 | CardHeaderTextTagColorLime CardHeaderTextTagColor = "lime"
526 | CardHeaderTextTagColorOrange CardHeaderTextTagColor = "orange"
527 | CardHeaderTextTagColorViolet CardHeaderTextTagColor = "violet"
528 | CardHeaderTextTagColorIndigo CardHeaderTextTagColor = "indigo"
529 | CardHeaderTextTagColorWathet CardHeaderTextTagColor = "wathet"
530 | CardHeaderTextTagColorGreen CardHeaderTextTagColor = "green"
531 | CardHeaderTextTagColorYellow CardHeaderTextTagColor = "yellow"
532 | CardHeaderTextTagColorRed CardHeaderTextTagColor = "red"
533 | CardHeaderTextTagColorPurple CardHeaderTextTagColor = "purple"
534 | CardHeaderTextTagColorCarmine CardHeaderTextTagColor = "carmine"
535 | )
536 |
537 | // --------------------------------------------------------------------------------
538 |
539 | var _ json.Marshaler = (*cardHeaderI18nTexts)(nil)
540 |
541 | type cardHeaderI18nTexts []cardHeaderI18nText
542 |
543 | func (ts cardHeaderI18nTexts) MarshalJSON() ([]byte, error) {
544 | if ts == nil {
545 | return []byte("null"), nil
546 | }
547 |
548 | var (
549 | languages = make([]Language, 0, len(ts))
550 | values = make(cardHeaderI18nTexts, 0, len(ts))
551 | )
552 | for _, t := range ts {
553 | if slices.Contains(languages, t.language) {
554 | continue
555 | }
556 | languages = append(languages, t.language)
557 | values = append(values, cardHeaderI18nText{text: t.text})
558 | }
559 |
560 | var buf bytes.Buffer
561 | buf.Grow(len(languages) * 24)
562 |
563 | buf.WriteString("{")
564 |
565 | commas := len(languages)
566 | for i := range languages {
567 | commas--
568 |
569 | language := string(languages[i])
570 | t := values[i]
571 |
572 | buf.WriteString(fmt.Sprintf(
573 | `"%s":"%s"`,
574 | _quoteEscaper.Replace(language), _quoteEscaper.Replace(t.text),
575 | ))
576 |
577 | if commas > 0 {
578 | buf.WriteString(",")
579 | }
580 | }
581 |
582 | buf.WriteString("}")
583 |
584 | return buf.Bytes(), nil
585 | }
586 |
587 | type cardHeaderI18nText struct {
588 | language Language
589 | text string
590 | }
591 |
592 | // --------------------------------------------------------------------------------
593 |
594 | type CardElement interface {
595 | Entity() any
596 | }
597 |
598 | var (
599 | _ CardElement = (*CardElementColumnSet)(nil)
600 | _ CardElement = (*CardElementDiv)(nil)
601 | _ CardElement = (*CardElementMarkdown)(nil)
602 | _ CardElement = (*CardElementAction)(nil)
603 | _ CardElement = (*CardElementHorizontalRule)(nil)
604 | _ CardElement = (*CardElementImage)(nil)
605 | _ CardElement = (*CardElementNote)(nil)
606 | )
607 |
608 | // ----------------------------------------
609 |
610 | // CardElementDiv
611 | //
612 | // https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/component-list/common-components-and-elements#6bdb3f37
613 | type CardElementDiv struct {
614 | div cardElementDiv
615 | }
616 |
617 | func (e *CardElementDiv) Entity() any {
618 | return e.div
619 | }
620 |
621 | type CardElementDivTextMode string
622 |
623 | const (
624 | CardElementDivTextModePlainText CardElementDivTextMode = "plain_text"
625 | CardElementDivTextModeLarkMarkdown CardElementDivTextMode = "lark_md"
626 | )
627 |
628 | type CardElementDivFieldText struct {
629 | // 是否并排布局
630 | // - true:并排
631 | // - false:不并排
632 | //
633 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/embedded-non-interactive-elements/field#3827dadd
634 | IsShort bool
635 |
636 | // Mode
637 | // - CardElementDivTextModePlainText
638 | // - CardElementDivTextModeLarkMarkdown
639 | //
640 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/embedded-non-interactive-elements/text#3827dadd
641 | Mode CardElementDivTextMode
642 |
643 | Content string
644 |
645 | // 内容显示行数
646 | //
647 | // 该字段仅支持 plain_text 模式,不支持 lark_md 模式
648 | //
649 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/embedded-non-interactive-elements/text#3827dadd
650 | Lines int
651 | }
652 |
653 | func NewCardElementDiv() *CardElementDiv {
654 | return &CardElementDiv{div: cardElementDiv{Tag: "div"}}
655 | }
656 |
657 | // PlainText 单个文本内容(普通文本内容)
658 | //
659 | // lines: 内容显示行数
660 | //
661 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/embedded-non-interactive-elements/text#3827dadd
662 | func (e *CardElementDiv) PlainText(content string, lines int) *CardElementDiv {
663 | e.div.Text = &cardElementDivText{
664 | Tag: string(CardElementDivTextModePlainText),
665 | Content: content,
666 | Lines: lines,
667 | }
668 | return e
669 | }
670 |
671 | // LarkMarkdown 单个文本内容(支持部分 Markdown 语法的文本内容)
672 | //
673 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/using-markdown-tags
674 | func (e *CardElementDiv) LarkMarkdown(content string) *CardElementDiv {
675 | e.div.Text = &cardElementDivText{
676 | Tag: string(CardElementDivTextModeLarkMarkdown),
677 | Content: content,
678 | }
679 | return e
680 | }
681 |
682 | // Fields 双列文本
683 | //
684 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/embedded-non-interactive-elements/field
685 | func (e *CardElementDiv) Fields(fields []CardElementDivFieldText) *CardElementDiv {
686 | val := make(cardElementDivFields, len(fields))
687 | for i := range fields {
688 | f := fields[i]
689 | val[i] = cardElementDivField{
690 | IsShort: f.IsShort,
691 | Text: cardElementDivText{
692 | Tag: string(f.Mode),
693 | Content: f.Content,
694 | Lines: f.Lines,
695 | },
696 | }
697 | }
698 | e.div.Fields = &val
699 | return e
700 | }
701 |
702 | // ExtraImage 在文本右侧附加图片元素
703 | //
704 | // preview: 点击后是否放大图片。在配置 card_link 后可设置为false,使用户点击卡片上的图片也能响应card_link链接跳转
705 | // altContent: 图片hover说明
706 | //
707 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/embedded-non-interactive-elements/image
708 | func (e *CardElementDiv) ExtraImage(imgKey string, preview bool, altContent string) *CardElementDiv {
709 | e.div.Extra = cardElementExtraImage{
710 | Tag: "img",
711 | ImgKey: imgKey,
712 | Alt: cardElementDivText{
713 | Tag: string(CardElementDivTextModePlainText),
714 | Content: altContent,
715 | },
716 | Preview: &preview,
717 | }
718 | return e
719 | }
720 |
721 | // ExtraAction 在文本右侧附加交互组件
722 | // - NewCardElementActionButton
723 | // - NewCardElementActionOverflow
724 | //
725 | // https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/component-list/common-components-and-elements#6bdb3f37
726 | func (e *CardElementDiv) ExtraAction(component CardElementActionComponent) *CardElementDiv {
727 | if component == nil {
728 | return e
729 | }
730 | e.div.Extra = component.ActionEntity()
731 | return e
732 | }
733 |
734 | func (e *CardElementDiv) _() *CardElementDiv {
735 | return e
736 | }
737 |
738 | // ----------------------------------------
739 |
740 | // CardElementMarkdown
741 | //
742 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/using-markdown-tags
743 | type CardElementMarkdown struct {
744 | md cardElementMarkdown
745 | }
746 |
747 | func (e *CardElementMarkdown) Entity() any {
748 | return e.md
749 | }
750 |
751 | type CardElementMarkdownTextAlign string
752 |
753 | const (
754 | CardElementMarkdownTextAlignLeft CardElementMarkdownTextAlign = "left"
755 | CardElementMarkdownTextAlignCenter CardElementMarkdownTextAlign = "center"
756 | CardElementMarkdownTextAlignRight CardElementMarkdownTextAlign = "right"
757 | )
758 |
759 | func NewCardElementMarkdown(content string) *CardElementMarkdown {
760 | return &CardElementMarkdown{md: cardElementMarkdown{Tag: "markdown", Content: content}}
761 | }
762 |
763 | func (e *CardElementMarkdown) Content(content string) *CardElementMarkdown {
764 | e.md.Content = content
765 | return e
766 | }
767 |
768 | // TextAlign 文本内容的对齐方式
769 | //
770 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/using-markdown-tags#3827dadd
771 | func (e *CardElementMarkdown) TextAlign(textAlign CardElementMarkdownTextAlign) *CardElementMarkdown {
772 | e.md.TextAlign = string(textAlign)
773 | return e
774 | }
775 |
776 | // Href 差异化跳转。仅在 PC 端、移动端需要跳转不同链接时使用
777 | //
778 | // [差异化跳转]($urlVal)
779 | //
780 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/using-markdown-tags#3827dadd
781 | func (e *CardElementMarkdown) Href(defaultURL, pc, ios, android string) *CardElementMarkdown {
782 | e.md.Href = &cardElementMarkdownHref{
783 | URLVal: differentJumpLinks{
784 | URL: defaultURL,
785 | PC: pc,
786 | IOS: ios,
787 | Android: android,
788 | },
789 | }
790 | return e
791 | }
792 |
793 | // ----------------------------------------
794 |
795 | // CardElementHorizontalRule 分割线
796 | //
797 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/divider-line-module
798 | type CardElementHorizontalRule struct {
799 | hr cardElementHorizontalRule
800 | }
801 |
802 | func (e *CardElementHorizontalRule) Entity() any {
803 | return e.hr
804 | }
805 |
806 | func NewCardElementHorizontalRule() CardElement {
807 | return &CardElementHorizontalRule{hr: cardElementHorizontalRule{Tag: "hr"}}
808 | }
809 |
810 | // ----------------------------------------
811 |
812 | // CardElementImage
813 | //
814 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/image-module#3827dadd
815 | type CardElementImage struct {
816 | img cardElementImage
817 | }
818 |
819 | func (e *CardElementImage) Entity() any {
820 | return e.img
821 | }
822 |
823 | func NewCardElementImage(imgKey, altContent string) *CardElementImage {
824 | return &CardElementImage{img: cardElementImage{
825 | Tag: "img",
826 | ImgKey: imgKey,
827 | Alt: cardElementDivText{
828 | Tag: string(CardElementDivTextModePlainText),
829 | Content: altContent,
830 | },
831 | Title: nil,
832 | CustomWidth: nil,
833 | CompactWidth: nil,
834 | Mode: "",
835 | Preview: nil,
836 | }}
837 | }
838 |
839 | // TitleWithPlainText 图片标题(plain_text)
840 | //
841 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/image-module#3827dadd
842 | func (e *CardElementImage) TitleWithPlainText(title string) *CardElementImage {
843 | e.img.Title = &cardElementDivText{
844 | Tag: string(CardElementDivTextModePlainText),
845 | Content: title,
846 | }
847 | return e
848 | }
849 |
850 | // TitleWithLarkMarkdown 图片标题(lark_md)
851 | //
852 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/image-module#3827dadd
853 | func (e *CardElementImage) TitleWithLarkMarkdown(title string) *CardElementImage {
854 | e.img.Title = &cardElementDivText{
855 | Tag: string(CardElementDivTextModeLarkMarkdown),
856 | Content: title,
857 | }
858 | return e
859 | }
860 |
861 | // CustomWidth 自定义图片的最大展示宽度,支持在 278px ~ 580px 范围内指定最大展示宽度
862 | //
863 | // 默认情况下图片宽度与图片组件所占区域的宽度一致
864 | //
865 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/image-module#3827dadd
866 | func (e *CardElementImage) CustomWidth(px int) *CardElementImage {
867 | e.img.CustomWidth = &px
868 | return e
869 | }
870 |
871 | // CompactWidth 是否展示为紧凑型的图片
872 | //
873 | // 默认值为 false。如果配置为 true,则展示最大宽度为 278px 的紧凑型图片
874 | func (e *CardElementImage) CompactWidth(b bool) *CardElementImage {
875 | e.img.CompactWidth = &b
876 | return e
877 | }
878 |
879 | type CardElementImageMode string
880 |
881 | const (
882 | CardElementImageModeCropCenter CardElementImageMode = "crop_center"
883 | CardElementImageModeFitHorizontal CardElementImageMode = "fit_horizontal"
884 | CardElementImageModeStretch CardElementImageMode = "stretch"
885 | CardElementImageModeLarge CardElementImageMode = "large"
886 | CardElementImageModeMedium CardElementImageMode = "medium"
887 | CardElementImageModeSmall CardElementImageMode = "small"
888 | CardElementImageModeTiny CardElementImageMode = "tiny"
889 | )
890 |
891 | // Mode 图片显示模式
892 | // - crop_center:居中裁剪模式,对长图会限高,并居中裁剪后展示
893 | // - fit_horizontal:平铺模式,宽度撑满卡片完整展示上传的图片
894 | // - stretch:自适应。图片宽度撑满卡片宽度,当图片 高:宽 小于 16:9 时,完整展示原图。当图片 高:宽 大于 16:9 时,顶部对齐裁剪图片,并在图片底部展示 长图 脚标
895 | // - large:大图,尺寸为 160 × 160,适用于多图混排
896 | // - medium:中图,尺寸为 80 × 80,适用于图文混排的封面图
897 | // - small:小图,尺寸为 40 × 40,适用于人员头像
898 | // - tiny:超小图,尺寸为 16 × 16,适用于图标、备注
899 | //
900 | // 注意:设置该参数后,会覆盖 custom_width 参数。更多信息参见 消息卡片设计规范 https://open.feishu.cn/document/tools-and-resources/design-specification/message-card-design-specifications
901 | //
902 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/image-module#3827dadd
903 | func (e *CardElementImage) Mode(mode CardElementImageMode) *CardElementImage {
904 | e.img.Mode = string(mode)
905 | return e
906 | }
907 |
908 | // Preview 点击后是否放大图片
909 | //
910 | // 默认值为 true,即点击后放大图片
911 | //
912 | // 如果你为卡片配置了 消息卡片跳转链接,
913 | // 可将该参数设置为 false,后续用户点击卡片上的图片也能响应 card_link 链接跳转
914 | func (e *CardElementImage) Preview(b bool) *CardElementImage {
915 | e.img.Preview = &b
916 | return e
917 | }
918 |
919 | // ----------------------------------------
920 |
921 | // CardElementNote 备注
922 | //
923 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/notes-module
924 | type CardElementNote struct {
925 | note cardElementNote
926 | }
927 |
928 | func (e *CardElementNote) Entity() any {
929 | return e.note
930 | }
931 |
932 | func NewCardElementNote() *CardElementNote {
933 | return &CardElementNote{note: cardElementNote{
934 | Tag: "note",
935 | Elements: make([]any, 0, 2),
936 | }}
937 | }
938 |
939 | // AddElementWithPlainText 添加文本(plain_text)
940 | //
941 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/notes-module#3827dadd
942 | func (e *CardElementNote) AddElementWithPlainText(s string) *CardElementNote {
943 | e.note.Elements = append(e.note.Elements,
944 | cardElementDivText{
945 | Tag: string(CardElementDivTextModePlainText),
946 | Content: s,
947 | },
948 | )
949 | return e
950 | }
951 |
952 | // AddElementWithLarkMarkdown 添加文本(lark_md)
953 | //
954 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/notes-module#3827dadd
955 | func (e *CardElementNote) AddElementWithLarkMarkdown(s string) *CardElementNote {
956 | e.note.Elements = append(e.note.Elements,
957 | cardElementDivText{
958 | Tag: string(CardElementDivTextModeLarkMarkdown),
959 | Content: s,
960 | },
961 | )
962 | return e
963 | }
964 |
965 | // AddElementWithImage 添加图片
966 | //
967 | // preview: 点击后是否放大图片。在配置 card_link 后可设置为false,使用户点击卡片上的图片也能响应card_link链接跳转
968 | // altContent: 图片hover说明
969 | //
970 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/notes-module#3827dadd
971 | func (e *CardElementNote) AddElementWithImage(imgKey string, preview bool, altContent string) *CardElementNote {
972 | e.note.Elements = append(e.note.Elements,
973 | cardElementExtraImage{
974 | Tag: "img",
975 | ImgKey: imgKey,
976 | Alt: cardElementDivText{
977 | Tag: string(CardElementDivTextModePlainText),
978 | Content: altContent,
979 | },
980 | Preview: &preview,
981 | },
982 | )
983 | return e
984 | }
985 |
986 | // ----------------------------------------
987 |
988 | // CardElementAction 交互模块(action)
989 | //
990 | // https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/component-list/common-components-and-elements#60ddc64e
991 | type CardElementAction struct {
992 | action cardElementAction
993 | }
994 |
995 | func (e *CardElementAction) Entity() any {
996 | return e.action
997 | }
998 |
999 | func NewCardElementAction() *CardElementAction {
1000 | return &CardElementAction{action: cardElementAction{
1001 | Tag: "action",
1002 | Actions: make([]any, 0, 3),
1003 | Layout: "",
1004 | }}
1005 | }
1006 |
1007 | func (e *CardElementAction) Actions(actions []CardElementActionComponent) *CardElementAction {
1008 | for i := range actions {
1009 | if actions[i] == nil {
1010 | continue
1011 | }
1012 | e.action.Actions = append(e.action.Actions, actions[i].ActionEntity())
1013 | }
1014 | return e
1015 | }
1016 |
1017 | type CardElementActionLayout string
1018 |
1019 | const (
1020 | CardElementActionLayoutBisected CardElementActionLayout = "bisected"
1021 | CardElementActionLayoutTrisection CardElementActionLayout = "trisection"
1022 | CardElementActionLayoutFlow CardElementActionLayout = "flow"
1023 | )
1024 |
1025 | // Layout 设置窄屏自适应布局方式
1026 | // - bisected:二等分布局,每行两列交互元素
1027 | // - trisection:三等分布局,每行三列交互元素
1028 | // - flow:流式布局,元素会按自身大小横向排列并在空间不够的时候折行
1029 | func (e *CardElementAction) Layout(layout CardElementActionLayout) *CardElementAction {
1030 | e.action.Layout = string(layout)
1031 | return e
1032 | }
1033 |
1034 | // ----------------------------------------
1035 |
1036 | // CardElementColumnSet 多列布局
1037 | //
1038 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/column-set
1039 | type CardElementColumnSet struct {
1040 | cs cardElementColumnSet
1041 | }
1042 |
1043 | func (e *CardElementColumnSet) Entity() any {
1044 | return e.cs
1045 | }
1046 |
1047 | func NewCardElementColumnSet() *CardElementColumnSet {
1048 | return &CardElementColumnSet{cs: cardElementColumnSet{
1049 | Tag: "column_set",
1050 | FlexMode: string(CardElementColumnSetFlexModeNone),
1051 | BackgroundStyle: "",
1052 | HorizontalSpacing: "",
1053 | Columns: make([]cardElementColumnSetColumn, 0, 2),
1054 | Action: nil,
1055 | }}
1056 | }
1057 |
1058 | type CardElementColumnSetFlexMode string
1059 |
1060 | const (
1061 | CardElementColumnSetFlexModeNone CardElementColumnSetFlexMode = "none"
1062 | CardElementColumnSetFlexModeStretch CardElementColumnSetFlexMode = "stretch"
1063 | CardElementColumnSetFlexModeFlow CardElementColumnSetFlexMode = "flow"
1064 | CardElementColumnSetFlexModeBisect CardElementColumnSetFlexMode = "bisect"
1065 | CardElementColumnSetFlexModeTrisect CardElementColumnSetFlexMode = "trisect"
1066 | )
1067 |
1068 | // FlexMode 移动端和 PC 端的窄屏幕下,各列的自适应方式
1069 | // - none:不做布局上的自适应,在窄屏幕下按比例压缩列宽度
1070 | // - stretch:列布局变为行布局,且每列(行)宽度强制拉伸为 100%,所有列自适应为上下堆叠排布
1071 | // - flow:列流式排布(自动换行),当一行展示不下一列时,自动换至下一行展示
1072 | // - bisect:两列等分布局
1073 | // - trisect:三列等分布局
1074 | //
1075 | // 默认值:none
1076 | //
1077 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/column-set#3827dadd
1078 | func (e *CardElementColumnSet) FlexMode(mode CardElementColumnSetFlexMode) *CardElementColumnSet {
1079 | e.cs.FlexMode = string(mode)
1080 | return e
1081 | }
1082 |
1083 | type CardElementColumnSetBackgroundStyle string
1084 |
1085 | const (
1086 | CardElementColumnSetBackgroundStyleDefault CardElementColumnSetBackgroundStyle = "default"
1087 | CardElementColumnSetBackgroundStyleGrey CardElementColumnSetBackgroundStyle = "grey"
1088 | )
1089 |
1090 | // BackgroundStyle 多列布局的背景色样式
1091 | // - default:默认的白底样式,dark mode 下为黑底
1092 | // - grey:灰底样式
1093 | //
1094 | // 当存在多列布局的嵌套时,上层多列布局的颜色覆盖下层多列布局的颜色
1095 | //
1096 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/column-set#3827dadd
1097 | func (e *CardElementColumnSet) BackgroundStyle(bs CardElementColumnSetBackgroundStyle) *CardElementColumnSet {
1098 | e.cs.BackgroundStyle = string(bs)
1099 | return e
1100 | }
1101 |
1102 | type CardElementColumnSetHorizontalSpacing string
1103 |
1104 | const (
1105 | CardElementColumnSetHorizontalSpacingDefault CardElementColumnSetHorizontalSpacing = "default"
1106 | CardElementColumnSetHorizontalSpacingSmall CardElementColumnSetHorizontalSpacing = "small"
1107 | )
1108 |
1109 | // HorizontalSpacing 多列布局内,各列之间的水平分栏间距
1110 | // - default:默认间距
1111 | // - small:窄间距
1112 | //
1113 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/column-set#3827dadd
1114 | func (e *CardElementColumnSet) HorizontalSpacing(hs CardElementColumnSetHorizontalSpacing) *CardElementColumnSet {
1115 | e.cs.HorizontalSpacing = string(hs)
1116 | return e
1117 | }
1118 |
1119 | // ActionMultiURL 设置点击布局容器时的交互配置。当前仅支持跳转交互。如果布局容器内有交互组件,则优先响应交互组件定义的交互
1120 | //
1121 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/column-set#3827dadd
1122 | func (e *CardElementColumnSet) ActionMultiURL(defaultURL, pc, ios, android string) *CardElementColumnSet {
1123 | e.cs.Action = &cardElementColumnSetAction{
1124 | MultiURL: differentJumpLinks{
1125 | URL: defaultURL,
1126 | PC: pc,
1127 | IOS: ios,
1128 | Android: android,
1129 | },
1130 | }
1131 | return e
1132 | }
1133 |
1134 | // Columns 多列布局容器内,各个列容器的配置信息
1135 | //
1136 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/column-set#3827dadd
1137 | func (e *CardElementColumnSet) Columns(columns []*CardElementColumnSetColumn) *CardElementColumnSet {
1138 | for i := range columns {
1139 | if columns[i] == nil || columns[i].csc.Tag == "" {
1140 | continue
1141 | }
1142 | e.cs.Columns = append(e.cs.Columns, columns[i].csc)
1143 | }
1144 | return e
1145 | }
1146 |
1147 | type CardElementColumnSetColumn struct {
1148 | csc cardElementColumnSetColumn
1149 | }
1150 |
1151 | func NewCardElementColumnSetColumn() *CardElementColumnSetColumn {
1152 | return &CardElementColumnSetColumn{csc: cardElementColumnSetColumn{
1153 | Tag: "column",
1154 | Width: "",
1155 | Weight: nil,
1156 | VerticalAlign: "",
1157 | Elements: nil,
1158 | }}
1159 | }
1160 |
1161 | type CardElementColumnSetColumnWidth string
1162 |
1163 | const (
1164 | CardElementColumnSetColumnWidthAuto CardElementColumnSetColumnWidth = "auto"
1165 | CardElementColumnSetColumnWidthWeighted CardElementColumnSetColumnWidth = "weighted"
1166 | )
1167 |
1168 | // Width 列宽度属性
1169 | // - auto:列宽度与列内元素宽度一致
1170 | // - weighted:列宽度按 weight 参数定义的权重分布
1171 | //
1172 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/column-set#1cf15c63
1173 | func (e *CardElementColumnSetColumn) Width(width CardElementColumnSetColumnWidth) *CardElementColumnSetColumn {
1174 | e.csc.Width = string(width)
1175 | return e
1176 | }
1177 |
1178 | // Weight 当 width 取值 weighted 时生效,表示当前列的宽度占比。取值范围:1 ~ 5
1179 | //
1180 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/column-set#1cf15c63
1181 | func (e *CardElementColumnSetColumn) Weight(n int) *CardElementColumnSetColumn {
1182 | e.csc.Weight = &n
1183 | return e
1184 | }
1185 |
1186 | type CardElementColumnSetColumnVerticalAlign string
1187 |
1188 | const (
1189 | CardElementColumnSetColumnVerticalAlignTop CardElementColumnSetColumnVerticalAlign = "top"
1190 | CardElementColumnSetColumnVerticalAlignCenter CardElementColumnSetColumnVerticalAlign = "center"
1191 | CardElementColumnSetColumnVerticalAlignBottom CardElementColumnSetColumnVerticalAlign = "bottom"
1192 | )
1193 |
1194 | // VerticalAlign 列内成员垂直对齐方式
1195 | // - top:顶对齐
1196 | // - center:居中对齐
1197 | // - bottom:底部对齐
1198 | func (e *CardElementColumnSetColumn) VerticalAlign(va CardElementColumnSetColumnVerticalAlign) *CardElementColumnSetColumn {
1199 | e.csc.VerticalAlign = string(va)
1200 | return e
1201 | }
1202 |
1203 | func (e *CardElementColumnSetColumn) Elements(elements []CardElement) *CardElementColumnSetColumn {
1204 | for i := range elements {
1205 | if elements[i] == nil {
1206 | continue
1207 | }
1208 |
1209 | e.csc.Elements = append(e.csc.Elements, elements[i].Entity())
1210 | }
1211 | return e
1212 | }
1213 |
1214 | // ----------------------------------------
1215 |
1216 | type CardElementActionComponent interface {
1217 | ActionEntity() any
1218 | }
1219 |
1220 | var (
1221 | _ CardElementActionComponent = (*CardElementActionButton)(nil)
1222 | _ CardElementActionComponent = (*CardElementActionOverflow)(nil)
1223 | )
1224 |
1225 | // CardElementActionButton
1226 | //
1227 | // https://open.feishu.cn/document/common-capabilities/message-card/add-card-interaction/interactive-components/button
1228 | type CardElementActionButton struct {
1229 | button cardElementActionButton
1230 | }
1231 |
1232 | func (e *CardElementActionButton) ActionEntity() any {
1233 | return e.button
1234 | }
1235 |
1236 | func NewCardElementActionButton(mode CardElementDivTextMode, content string) *CardElementActionButton {
1237 | return &CardElementActionButton{button: cardElementActionButton{
1238 | Tag: "button",
1239 | Text: cardElementDivText{
1240 | Tag: string(mode),
1241 | Content: content,
1242 | },
1243 | URL: "",
1244 | MultiURL: nil,
1245 | Type: "",
1246 | }}
1247 | }
1248 |
1249 | // URL 点击按钮后的跳转链接。不可与 MultiURL 同时设置
1250 | //
1251 | // https://open.feishu.cn/document/common-capabilities/message-card/add-card-interaction/interactive-components/button#3827dadd
1252 | func (e *CardElementActionButton) URL(s string) *CardElementActionButton {
1253 | e.button.URL = s
1254 | return e
1255 | }
1256 |
1257 | // MultiURL 基于 url 元素配置多端跳转链接,不可与 URL 同时设置
1258 | //
1259 | // https://open.feishu.cn/document/common-capabilities/message-card/add-card-interaction/interactive-components/button#3827dadd
1260 | func (e *CardElementActionButton) MultiURL(defaultURL, pc, ios, android string) *CardElementActionButton {
1261 | e.button.MultiURL = &differentJumpLinks{
1262 | URL: defaultURL,
1263 | PC: pc,
1264 | IOS: ios,
1265 | Android: android,
1266 | }
1267 | return e
1268 | }
1269 |
1270 | type CardElementActionButtonType string
1271 |
1272 | const (
1273 | CardElementActionButtonTypeDefault CardElementActionButtonType = "default"
1274 | CardElementActionButtonTypePrimary CardElementActionButtonType = "primary"
1275 | CardElementActionButtonTypeDanger CardElementActionButtonType = "danger"
1276 | )
1277 |
1278 | // Type 配置按钮样式
1279 | // - default:默认样式
1280 | // - primary:强调样式
1281 | // - danger:警示样式
1282 | //
1283 | // https://open.feishu.cn/document/common-capabilities/message-card/add-card-interaction/interactive-components/button#3827dadd
1284 | func (e *CardElementActionButton) Type(typ CardElementActionButtonType) *CardElementActionButton {
1285 | e.button.Type = string(typ)
1286 | return e
1287 | }
1288 |
1289 | // Confirm 设置二次确认弹框
1290 | //
1291 | // https://open.feishu.cn/document/common-capabilities/message-card/add-card-interaction/interactive-components/button?lang=zh-CN#3827dadd
1292 | func (e *CardElementActionButton) Confirm(title, text string) *CardElementActionButton {
1293 | e.button.Confirm = &cardElementActionConfirm{
1294 | Title: cardElementDivText{
1295 | Tag: string(CardElementDivTextModePlainText),
1296 | Content: title,
1297 | },
1298 | Text: cardElementDivText{
1299 | Tag: string(CardElementDivTextModePlainText),
1300 | Content: text,
1301 | },
1302 | }
1303 | return e
1304 | }
1305 |
1306 | // ----------------------------------------
1307 |
1308 | // CardElementActionOverflow 折叠按钮组(overflow)
1309 | //
1310 | // https://open.feishu.cn/document/common-capabilities/message-card/add-card-interaction/interactive-components/overflow
1311 | type CardElementActionOverflow struct {
1312 | overflow cardElementActionOverflow
1313 | }
1314 |
1315 | func (e *CardElementActionOverflow) ActionEntity() any {
1316 | return e.overflow
1317 | }
1318 |
1319 | func NewCardElementActionOverflow() *CardElementActionOverflow {
1320 | return &CardElementActionOverflow{overflow: cardElementActionOverflow{
1321 | Tag: "overflow",
1322 | Options: make([]cardElementActionOption, 0, 2),
1323 | Confirm: nil,
1324 | }}
1325 | }
1326 |
1327 | // AddOptionWithURL 添加跳转链接的选项
1328 | //
1329 | // https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/component-list/common-components-and-elements#9fa21514
1330 | func (e *CardElementActionOverflow) AddOptionWithURL(text, defaultURL string) *CardElementActionOverflow {
1331 | e.overflow.Options = append(e.overflow.Options, cardElementActionOption{
1332 | Text: cardElementDivText{
1333 | Tag: string(CardElementDivTextModePlainText),
1334 | Content: text,
1335 | },
1336 | URL: defaultURL,
1337 | MultiURL: nil,
1338 | })
1339 | return e
1340 | }
1341 |
1342 | // AddOptionWithMultiURL 添加多端跳转链接的选项
1343 | //
1344 | // https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/component-list/common-components-and-elements#9fa21514
1345 | func (e *CardElementActionOverflow) AddOptionWithMultiURL(text, defaultURL, pc, ios, android string) *CardElementActionOverflow {
1346 | e.overflow.Options = append(e.overflow.Options, cardElementActionOption{
1347 | Text: cardElementDivText{
1348 | Tag: string(CardElementDivTextModePlainText),
1349 | Content: text,
1350 | },
1351 | URL: "",
1352 | MultiURL: &differentJumpLinks{
1353 | URL: defaultURL,
1354 | PC: pc,
1355 | IOS: ios,
1356 | Android: android,
1357 | },
1358 | })
1359 | return e
1360 | }
1361 |
1362 | // Confirm 设置二次确认弹框
1363 | //
1364 | // https://open.feishu.cn/document/common-capabilities/message-card/add-card-interaction/interactive-components/overflow#3827dadd
1365 | func (e *CardElementActionOverflow) Confirm(title, text string) *CardElementActionOverflow {
1366 | e.overflow.Confirm = &cardElementActionConfirm{
1367 | Title: cardElementDivText{
1368 | Tag: string(CardElementDivTextModePlainText),
1369 | Content: title,
1370 | },
1371 | Text: cardElementDivText{
1372 | Tag: string(CardElementDivTextModePlainText),
1373 | Content: text,
1374 | },
1375 | }
1376 | return e
1377 | }
1378 |
1379 | // ----------------------------------------
1380 |
1381 | type (
1382 | // cardElementDiv 内容模块(div)
1383 | //
1384 | // https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/component-list/common-components-and-elements#6bdb3f37
1385 | cardElementDiv struct {
1386 | // 内容模块的标识。固定取值:div
1387 | Tag string `json:"tag"`
1388 |
1389 | // 单个文本内容
1390 | //
1391 | // 参数配置详情: https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/embedded-non-interactive-elements/text
1392 | Text *cardElementDivText `json:"text,omitempty"`
1393 |
1394 | // 双列文本
1395 | //
1396 | // 参数配置详情: https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/embedded-non-interactive-elements/field
1397 | Fields *cardElementDivFields `json:"fields,omitempty"`
1398 |
1399 | // 附加元素,添加后展示在文本右侧。支持附加的元素:
1400 | // - 图片(image)
1401 | // - 按钮(button)
1402 | // - 列表选择器(selectMenu)
1403 | // - 折叠按钮组(overflow)
1404 | // - 日期选择器(datePicker)
1405 | Extra any `json:"extra,omitempty"`
1406 | }
1407 |
1408 | // cardElementDivText
1409 | //
1410 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/embedded-non-interactive-elements/text
1411 | cardElementDivText struct {
1412 | // 文本元素的标签
1413 | // - plain_text:普通文本内容
1414 | // - lark_md:支持部分 Markdown 语法的文本内容。关于 Markdown 语法的详细介绍,可参见 Markdown https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/using-markdown-tags
1415 | Tag string `json:"tag"`
1416 |
1417 | Content string `json:"content"`
1418 |
1419 | // 内容显示行数
1420 | //
1421 | // 该字段仅支持 text 的 plain_text 模式,不支持 lark_md 模式
1422 | Lines int `json:"lines,omitempty"`
1423 | }
1424 |
1425 | // cardElementDivFields 双列文本
1426 | //
1427 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/embedded-non-interactive-elements/field
1428 | cardElementDivFields []cardElementDivField
1429 | // cardElementDivField
1430 | //
1431 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/embedded-non-interactive-elements/field#3827dadd
1432 | cardElementDivField struct {
1433 | // 是否并排布局
1434 | // - true:并排
1435 | // - false:不并排
1436 | IsShort bool `json:"is_short"`
1437 |
1438 | Text cardElementDivText `json:"text"`
1439 | }
1440 |
1441 | // cardElementExtraImage 内容元素的一种,可用于内容块的extra字段和备注块的elements字段
1442 | //
1443 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/embedded-non-interactive-elements/image
1444 | cardElementExtraImage struct {
1445 | // 元素标签。固定取值:img
1446 | Tag string `json:"tag"`
1447 |
1448 | // 图片资源,获取方式:https://open.feishu.cn/document/server-docs/im-v1/image/create?appId=cli_a2850f3fbb38900d
1449 | ImgKey string `json:"img_key"`
1450 |
1451 | // 图片hover说明
1452 | Alt cardElementDivText `json:"alt"`
1453 |
1454 | // 点击后是否放大图片,缺省为true。
1455 | //
1456 | // 在配置 card_link 后可设置为false,使用户点击卡片上的图片也能响应card_link链接跳转
1457 | Preview *bool `json:"preview,omitempty"`
1458 | }
1459 |
1460 | // cardElementMarkdown
1461 | //
1462 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/using-markdown-tags
1463 | cardElementMarkdown struct {
1464 | // Markdown 组件的标识。固定取值:markdown
1465 | Tag string `json:"tag"`
1466 |
1467 | // 使用已支持的 Markdown 语法构造 Markdown 内容。
1468 | //
1469 | // 语法详情:https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/using-markdown-tags#abc9b025
1470 | Content string `json:"content"`
1471 |
1472 | // 文本内容的对齐方式
1473 | // - left:左对齐
1474 | // - center:居中对齐
1475 | // - right:右对齐
1476 | TextAlign string `json:"text_align,omitempty"`
1477 |
1478 | // 差异化跳转。仅在 PC 端、移动端需要跳转不同链接时使用
1479 | Href *cardElementMarkdownHref `json:"href,omitempty"`
1480 | }
1481 |
1482 | cardElementMarkdownHref struct {
1483 | URLVal differentJumpLinks `json:"urlVal"`
1484 | }
1485 |
1486 | // cardElementHorizontalRule
1487 | //
1488 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/divider-line-module
1489 | cardElementHorizontalRule struct {
1490 | // 分割线模块标识,固定取值:hr
1491 | Tag string `json:"tag"`
1492 | }
1493 |
1494 | // cardElementImage
1495 | //
1496 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/image-module#3827dadd
1497 | cardElementImage struct {
1498 | // 图片组件的标签,固定取值:img
1499 | Tag string `json:"tag"`
1500 |
1501 | ImgKey string `json:"img_key"`
1502 |
1503 | // 悬浮(hover)图片时弹出的说明文案
1504 | //
1505 | // 使用文本组件的数据结构展示文案,详情参见文本组件
1506 | //
1507 | // 当文本组件的 content 参数取值为空时,不展示图片文案内容
1508 | Alt cardElementDivText `json:"alt"`
1509 |
1510 | // 图片标题
1511 | Title *cardElementDivText `json:"title,omitempty"`
1512 |
1513 | // 自定义图片的最大展示宽度,支持在 278px ~ 580px 范围内指定最大展示宽度
1514 | //
1515 | // 默认情况下图片宽度与图片组件所占区域的宽度一致
1516 | CustomWidth *int `json:"custom_width,omitempty"`
1517 |
1518 | // 是否展示为紧凑型的图片。
1519 | //
1520 | // 默认值为 false。如果配置为 true,则展示最大宽度为 278px 的紧凑型图片。
1521 | CompactWidth *bool `json:"compact_width,omitempty"`
1522 |
1523 | // 图片显示模式
1524 | // - crop_center:居中裁剪模式,对长图会限高,并居中裁剪后展示
1525 | // - fit_horizontal:平铺模式,宽度撑满卡片完整展示上传的图片
1526 | // - stretch:自适应。图片宽度撑满卡片宽度,当图片 高:宽 小于 16:9 时,完整展示原图。当图片 高:宽 大于 16:9 时,顶部对齐裁剪图片,并在图片底部展示 长图 脚标
1527 | // - large:大图,尺寸为 160 × 160,适用于多图混排
1528 | // - medium:中图,尺寸为 80 × 80,适用于图文混排的封面图
1529 | // - small:小图,尺寸为 40 × 40,适用于人员头像
1530 | // - tiny:超小图,尺寸为 16 × 16,适用于图标、备注
1531 | //
1532 | // 注意:设置该参数后,会覆盖 custom_width 参数。更多信息参见 消息卡片设计规范 https://open.feishu.cn/document/tools-and-resources/design-specification/message-card-design-specifications
1533 | Mode string `json:"mode,omitempty"`
1534 |
1535 | // 点击后是否放大图片
1536 | //
1537 | // 默认值为 true,即点击后放大图片
1538 | //
1539 | // 如果你为卡片配置了 消息卡片跳转链接,
1540 | // 可将该参数设置为 false,后续用户点击卡片上的图片也能响应 card_link 链接跳转
1541 | Preview *bool `json:"preview,omitempty"`
1542 | }
1543 |
1544 | cardElementNote struct {
1545 | // 备注组件的标识。固定取值:note
1546 | Tag string `json:"tag"`
1547 |
1548 | // 备注信息。支持添加的元素:
1549 | // - 文本组件的数据结构,构成备注信息的文本内容。数据结构参见文本组件 https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/embedded-non-interactive-elements/text
1550 | // - image 元素的数据结构,构成备注信息的小尺寸图片。数据结构参见 image 元素 https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/component-list/common-components-and-elements#a974e363
1551 | Elements []any `json:"elements"`
1552 | }
1553 |
1554 | cardElementAction struct {
1555 | // 交互模块的标识。固定取值:action
1556 | Tag string `json:"tag"`
1557 |
1558 | // 添加可交互的组件。支持添加的组件:
1559 | // - 按钮(button)
1560 | // - 列表选择器(selectMenu)
1561 | // - 折叠按钮组(overflow)
1562 | // - 日期选择器(datePicker)
1563 | Actions []any `json:"actions"`
1564 |
1565 | // 设置窄屏自适应布局方式
1566 | // - bisected:二等分布局,每行两列交互元素
1567 | // - trisection:三等分布局,每行三列交互元素
1568 | // - flow:流式布局,元素会按自身大小横向排列并在空间不够的时候折行
1569 | Layout string `json:"layout,omitempty"`
1570 | }
1571 |
1572 | // cardElementActionButton
1573 | //
1574 | // https://open.feishu.cn/document/common-capabilities/message-card/add-card-interaction/interactive-components/button
1575 | cardElementActionButton struct {
1576 | // 按钮组件的标识。固定取值:button
1577 | Tag string `json:"tag"`
1578 |
1579 | // 按钮中的文本。基于文本组件的数据结构配置文本内容,详情参见文本组件 https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/embedded-non-interactive-elements/text
1580 | Text cardElementDivText `json:"text"`
1581 |
1582 | // 点击按钮后的跳转链接。该字段与 multi_url 字段不可同时设置
1583 | URL string `json:"url,omitempty"`
1584 |
1585 | // 基于 url 元素配置多端跳转链接,详情参见url 元素。该字段与 url 字段不可同时设置 https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/component-list/common-components-and-elements#09a320b3
1586 | MultiURL *differentJumpLinks `json:"multi_url,omitempty"`
1587 |
1588 | // 配置按钮样式
1589 | // - default:默认样式
1590 | // - primary:强调样式
1591 | // - danger:警示样式
1592 | Type string `json:"type,omitempty"`
1593 |
1594 | // value 该字段用于交互组件的回传交互方式,当用户点击交互组件后,会将 value 的值返回给接收回调数据的服务器。后续你可以通过服务器接收的 value 值进行业务处理
1595 | //
1596 | // 自定义机器人发送的消息卡片,只支持通过按钮、文字链方式跳转 URL,不支持点击后回调信息到服务端的回传交互
1597 | // https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot?lang=zh-CN#4996824a
1598 | // https://open.feishu.cn/document/common-capabilities/message-card/add-card-interaction/interaction-module
1599 |
1600 | // 设置二次确认弹框
1601 | //
1602 | // confirm 元素的配置方式可参见 confirm https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/component-list/common-components-and-elements#7f700aa9
1603 | Confirm *cardElementActionConfirm `json:"confirm,omitempty"`
1604 | }
1605 |
1606 | // cardElementActionConfirm
1607 | //
1608 | // https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/component-list/common-components-and-elements#7f700aa9
1609 | cardElementActionConfirm struct {
1610 | // 弹窗标题。由文本组件构成(仅支持文本组件的 plain_text 模式)
1611 | //
1612 | // https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/component-list/common-components-and-elements#7f700aa9
1613 | Title cardElementDivText `json:"title"`
1614 |
1615 | // 弹窗内容。由文本组件构成(仅支持文本组件的 plain_text 模式)
1616 | //
1617 | // https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/component-list/common-components-and-elements#7f700aa9
1618 | Text cardElementDivText `json:"text"`
1619 | }
1620 |
1621 | // cardElementActionOverflow
1622 | //
1623 | // https://open.feishu.cn/document/common-capabilities/message-card/add-card-interaction/interactive-components/overflow
1624 | cardElementActionOverflow struct {
1625 | // 折叠按钮组的标签。固定取值:overflow
1626 | Tag string `json:"tag"`
1627 |
1628 | // 折叠按钮组当中的选项按钮。按钮基于 option 元素进行配置
1629 | //
1630 | // 详情参见 option 元素 https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/component-list/common-components-and-elements#9fa21514
1631 | Options []cardElementActionOption `json:"options"`
1632 |
1633 | // 设置二次确认弹框。confirm 元素的配置方式可参见 confirm https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/component-list/common-components-and-elements#7f700aa9
1634 | Confirm *cardElementActionConfirm `json:"confirm,omitempty"`
1635 | }
1636 |
1637 | // cardElementActionOption
1638 | //
1639 | // https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/component-list/common-components-and-elements#9fa21514
1640 | cardElementActionOption struct {
1641 | // 选项显示的内容
1642 | Text cardElementDivText `json:"text"`
1643 |
1644 | // 选项的跳转链接,仅支持在折叠按钮组(overflow)中设置
1645 | //
1646 | // url 和 multi_url 字段必须且仅能填写其中一个
1647 | URL string `json:"url,omitempty"`
1648 |
1649 | // 选项的跳转链接,仅支持在折叠按钮组(overflow)中设置。支持按操作系统设置不同的链接,参数配置详情参见 链接元素(url)
1650 | //
1651 | // url 和 multi_url 字段必须且仅能填写其中一个
1652 | MultiURL *differentJumpLinks `json:"multi_url,omitempty"`
1653 | }
1654 |
1655 | // cardElementColumnSet
1656 | //
1657 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/column-set
1658 | cardElementColumnSet struct {
1659 | // 多列布局容器的标识,固定取值:column_set
1660 | Tag string `json:"tag"`
1661 |
1662 | // 移动端和 PC 端的窄屏幕下,各列的自适应方式
1663 | // - none:不做布局上的自适应,在窄屏幕下按比例压缩列宽度
1664 | // - stretch:列布局变为行布局,且每列(行)宽度强制拉伸为 100%,所有列自适应为上下堆叠排布
1665 | // - flow:列流式排布(自动换行),当一行展示不下一列时,自动换至下一行展示
1666 | // - bisect:两列等分布局
1667 | // - trisect:三列等分布局
1668 | //
1669 | // 默认值:none
1670 | FlexMode string `json:"flex_mode"`
1671 |
1672 | // 多列布局的背景色样式
1673 | // - default:默认的白底样式,dark mode 下为黑底
1674 | // - grey:灰底样式
1675 | //
1676 | // 当存在多列布局的嵌套时,上层多列布局的颜色覆盖下层多列布局的颜色
1677 | BackgroundStyle string `json:"background_style,omitempty"`
1678 |
1679 | // 多列布局内,各列之间的水平分栏间距
1680 | // - default:默认间距
1681 | // - small:窄间距
1682 | HorizontalSpacing string `json:"horizontal_spacing,omitempty"`
1683 |
1684 | // 多列布局容器内,各个列容器的配置信息
1685 | //
1686 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/column-set#3827dadd
1687 | Columns []cardElementColumnSetColumn `json:"columns,omitempty"`
1688 |
1689 | // 设置点击布局容器时的交互配置。当前仅支持跳转交互。如果布局容器内有交互组件,则优先响应交互组件定义的交互
1690 | //
1691 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/column-set#3827dadd
1692 | Action *cardElementColumnSetAction `json:"action,omitempty"`
1693 | }
1694 | cardElementColumnSetAction struct {
1695 | MultiURL differentJumpLinks `json:"multi_url"`
1696 | }
1697 |
1698 | // cardElementColumnSetColumn
1699 | //
1700 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/column-set#1cf15c63
1701 | cardElementColumnSetColumn struct {
1702 | // 列容器标识,固定取值:column
1703 | Tag string `json:"tag"`
1704 |
1705 | // 列宽度属性
1706 | // - auto:列宽度与列内元素宽度一致
1707 | // - weighted:列宽度按 weight 参数定义的权重分布
1708 | Width string `json:"width,omitempty"`
1709 |
1710 | // 当 width 取值 weighted 时生效,表示当前列的宽度占比。取值范围:1 ~ 5
1711 | Weight *int `json:"weight,omitempty"`
1712 |
1713 | // 列内成员垂直对齐方式
1714 | // - top:顶对齐
1715 | // - center:居中对齐
1716 | // - bottom:底部对齐
1717 | VerticalAlign string `json:"vertical_align,omitempty"`
1718 |
1719 | // 需要在列内展示的卡片元素
1720 | //
1721 | // https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/column-set#1cf15c63
1722 | Elements []any `json:"elements,omitempty"`
1723 | }
1724 | )
1725 |
--------------------------------------------------------------------------------
/msg_card_template.go:
--------------------------------------------------------------------------------
1 | package feishu_bot_api
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | )
7 |
8 | var _ Message = (*cardMessageViaTemplate)(nil)
9 |
10 | type cardMessageViaTemplate struct {
11 | id string
12 | variables any
13 | }
14 |
15 | func (m cardMessageViaTemplate) Apply(body *MessageBody) error {
16 | rawVariables, err := json.Marshal(m.variables)
17 | if err != nil {
18 | return fmt.Errorf("card message(template): marshal variables: %w", err)
19 | }
20 |
21 | tpl := MessageBodyCardTemplate{
22 | Type: "template",
23 | Data: MessageBodyCardTemplateData{
24 | TemplateID: m.id,
25 | TemplateVariable: (*json.RawMessage)(&rawVariables),
26 | },
27 | }
28 | rawTpl, err := json.Marshal(tpl)
29 | if err != nil {
30 | return fmt.Errorf("card message(template): marshal: %w", err)
31 | }
32 |
33 | body.MsgType = "interactive"
34 | body.Card = (*json.RawMessage)(&rawTpl)
35 | return nil
36 | }
37 |
--------------------------------------------------------------------------------
/msg_card_test.go:
--------------------------------------------------------------------------------
1 | package feishu_bot_api
2 |
3 | import (
4 | "encoding/json"
5 | "reflect"
6 | "testing"
7 | )
8 |
9 | func Test_cardMessageHeaderI18nTexts_MarshalJSON(t *testing.T) {
10 | tests := []struct {
11 | name string
12 | ts cardHeaderI18nTexts
13 | want []byte
14 | wantErr bool
15 | }{
16 | {
17 | name: "case_1",
18 | ts: nil,
19 | want: []byte(`null`),
20 | wantErr: false,
21 | },
22 | {
23 | name: "case_2",
24 | ts: cardHeaderI18nTexts{
25 | cardHeaderI18nText{language: "zh_cn", text: "这是主标题!"},
26 | },
27 | want: []byte(`{"zh_cn":"这是主标题!"}`),
28 | wantErr: false,
29 | },
30 | {
31 | name: "case_3",
32 | ts: cardHeaderI18nTexts{
33 | cardHeaderI18nText{language: "zh_cn", text: "这是主标题!"},
34 | cardHeaderI18nText{language: "en_us", text: "It is the title!"},
35 | },
36 | want: []byte(`{"zh_cn":"这是主标题!","en_us":"It is the title!"}`),
37 | wantErr: false,
38 | },
39 | {
40 | name: "case_4",
41 | ts: cardHeaderI18nTexts{
42 | cardHeaderI18nText{language: "zh_cn", text: `这是"主标题"!`},
43 | cardHeaderI18nText{language: "en_us", text: "It is the title!"},
44 | },
45 | want: []byte(`{"zh_cn":"这是\"主标题\"!","en_us":"It is the title!"}`),
46 | wantErr: false,
47 | },
48 | {
49 | name: "case_5",
50 | ts: cardHeaderI18nTexts{
51 | cardHeaderI18nText{language: "zh_cn", text: `\这是"主标题"!`},
52 | cardHeaderI18nText{language: "en_us", text: "It is the title!"},
53 | },
54 | want: []byte(`{"zh_cn":"\\这是\"主标题\"!","en_us":"It is the title!"}`),
55 | wantErr: false,
56 | },
57 | {
58 | name: "case_6",
59 | ts: cardHeaderI18nTexts{
60 | cardHeaderI18nText{language: "zh_cn", text: `这是主标题!`},
61 | cardHeaderI18nText{language: "en_us", text: "It is the title!"},
62 | cardHeaderI18nText{language: "zh_cn", text: "这是多余的标题"},
63 | },
64 | want: []byte(`{"zh_cn":"这是主标题!","en_us":"It is the title!"}`),
65 | wantErr: false,
66 | },
67 | {
68 | name: "case_7",
69 | ts: cardHeaderI18nTexts{
70 | cardHeaderI18nText{language: "en_us", text: "It is the title!"},
71 | cardHeaderI18nText{language: "zh_cn", text: `这是主标题!`},
72 | cardHeaderI18nText{language: "zh_cn", text: "这是多余的标题"},
73 | },
74 | want: []byte(`{"en_us":"It is the title!","zh_cn":"这是主标题!"}`),
75 | wantErr: false,
76 | },
77 | }
78 | for _, tt := range tests {
79 | t.Run(tt.name, func(t *testing.T) {
80 | got, err := tt.ts.MarshalJSON()
81 | if (err != nil) != tt.wantErr {
82 | t.Errorf("MarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
83 | return
84 | }
85 | if !reflect.DeepEqual(got, tt.want) {
86 | t.Errorf("MarshalJSON()\n got = %s\nwant = %s", got, tt.want)
87 | }
88 | })
89 | }
90 | }
91 |
92 | func Test_cardMessageHeaderI18nTextTags_MarshalJSON(t *testing.T) {
93 | tests := []struct {
94 | name string
95 | tts cardHeaderI18nTextTags
96 | want []byte
97 | wantErr bool
98 | }{
99 | {
100 | name: "case_1",
101 | tts: nil,
102 | want: []byte(`null`),
103 | wantErr: false,
104 | },
105 | {
106 | name: "case_2",
107 | tts: cardHeaderI18nTextTags{
108 | cardHeaderI18nTextTag{
109 | language: "zh_cn",
110 | Tag: "text_tag",
111 | Text: cardHeaderComponentPlainText{Tag: "plain_text", Content: "标签内容"},
112 | Color: "carmine",
113 | },
114 | },
115 | want: []byte(`{"zh_cn":[{"tag":"text_tag","text":{"tag":"plain_text","content":"标签内容"},"color":"carmine"}]}`),
116 | wantErr: false,
117 | },
118 | {
119 | name: "case_3",
120 | tts: cardHeaderI18nTextTags{
121 | cardHeaderI18nTextTag{
122 | language: "zh_cn",
123 | Tag: "text_tag",
124 | Text: cardHeaderComponentPlainText{Tag: "plain_text", Content: "标签内容"},
125 | Color: "carmine",
126 | },
127 | cardHeaderI18nTextTag{
128 | language: "zh_cn",
129 | Tag: "text_tag",
130 | Text: cardHeaderComponentPlainText{Tag: "plain_text", Content: "标签内容2"},
131 | Color: "carmine",
132 | },
133 | },
134 | want: []byte(`{"zh_cn":[{"tag":"text_tag","text":{"tag":"plain_text","content":"标签内容"},"color":"carmine"},{"tag":"text_tag","text":{"tag":"plain_text","content":"标签内容2"},"color":"carmine"}]}`),
135 | wantErr: false,
136 | },
137 | {
138 | name: "case_4",
139 | tts: cardHeaderI18nTextTags{
140 | cardHeaderI18nTextTag{
141 | language: "zh_cn",
142 | Tag: "text_tag",
143 | Text: cardHeaderComponentPlainText{Tag: "plain_text", Content: "标签内容"},
144 | Color: "carmine",
145 | },
146 | cardHeaderI18nTextTag{
147 | language: "zh_cn",
148 | Tag: "text_tag",
149 | Text: cardHeaderComponentPlainText{Tag: "plain_text", Content: "标签内容2"},
150 | Color: "carmine",
151 | },
152 | cardHeaderI18nTextTag{
153 | language: "en_us",
154 | Tag: "text_tag",
155 | Text: cardHeaderComponentPlainText{Tag: "plain_text", Content: "Tag content"},
156 | Color: "carmine",
157 | },
158 | },
159 | want: []byte(`{"zh_cn":[{"tag":"text_tag","text":{"tag":"plain_text","content":"标签内容"},"color":"carmine"},{"tag":"text_tag","text":{"tag":"plain_text","content":"标签内容2"},"color":"carmine"}],"en_us":[{"tag":"text_tag","text":{"tag":"plain_text","content":"Tag content"},"color":"carmine"}]}`),
160 | wantErr: false,
161 | },
162 | {
163 | name: "case_5",
164 | tts: cardHeaderI18nTextTags{
165 | cardHeaderI18nTextTag{
166 | language: "zh_cn",
167 | Tag: "text_tag",
168 | Text: cardHeaderComponentPlainText{Tag: "plain_text", Content: "标签内容"},
169 | Color: "carmine",
170 | },
171 | cardHeaderI18nTextTag{
172 | language: "en_us",
173 | Tag: "text_tag",
174 | Text: cardHeaderComponentPlainText{Tag: "plain_text", Content: "Tag content"},
175 | Color: "carmine",
176 | },
177 | cardHeaderI18nTextTag{
178 | language: "zh_cn",
179 | Tag: "text_tag",
180 | Text: cardHeaderComponentPlainText{Tag: "plain_text", Content: "标签内容2"},
181 | Color: "carmine",
182 | },
183 | cardHeaderI18nTextTag{
184 | language: "en_us",
185 | Tag: "text_tag",
186 | Text: cardHeaderComponentPlainText{Tag: "plain_text", Content: "Tag content 2"},
187 | Color: "",
188 | },
189 | },
190 | want: []byte(`{"zh_cn":[{"tag":"text_tag","text":{"tag":"plain_text","content":"标签内容"},"color":"carmine"},{"tag":"text_tag","text":{"tag":"plain_text","content":"标签内容2"},"color":"carmine"}],"en_us":[{"tag":"text_tag","text":{"tag":"plain_text","content":"Tag content"},"color":"carmine"},{"tag":"text_tag","text":{"tag":"plain_text","content":"Tag content 2"}}]}`),
191 | wantErr: false,
192 | },
193 | }
194 | for _, tt := range tests {
195 | t.Run(tt.name, func(t *testing.T) {
196 | got, err := tt.tts.MarshalJSON()
197 | if (err != nil) != tt.wantErr {
198 | t.Errorf("MarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
199 | return
200 | }
201 | if !reflect.DeepEqual(got, tt.want) {
202 | t.Errorf("MarshalJSON()\n got = %s\nwant = %s", got, tt.want)
203 | }
204 | })
205 | }
206 | }
207 |
208 | func Test_cardMessage_buildHeader_output(t *testing.T) {
209 | m := cardMessage{
210 | globalConf: &CardGlobalConfig{
211 | headerIcon: &cardHeaderIcon{ImgKey: "img_v2_881d0c9c-8717-49a7-b075-1cca6562443g"},
212 | headerTemplate: "red",
213 | config: nil,
214 | link: nil,
215 | },
216 | builders: []*CardBuilder{
217 | {
218 | language: "zh_cn",
219 | headerTitleContent: "中文环境下的主标题",
220 | headerSubtitleContent: "中文环境下的副标题",
221 | headerI18nTextTags: &(cardHeaderI18nTextTags{
222 | cardHeaderI18nTextTag{
223 | language: "zh_cn",
224 | Tag: "text_tag",
225 | Text: cardHeaderComponentPlainText{
226 | Tag: "plain_text",
227 | Content: "标题标签",
228 | },
229 | Color: "carmine",
230 | },
231 | }),
232 | },
233 | {
234 | language: "en_us",
235 | headerTitleContent: "英语环境下的主标题",
236 | headerSubtitleContent: "英语环境下的副标题",
237 | headerI18nTextTags: &(cardHeaderI18nTextTags{
238 | cardHeaderI18nTextTag{
239 | language: "en_us",
240 | Tag: "text_tag",
241 | Text: cardHeaderComponentPlainText{
242 | Tag: "plain_text",
243 | Content: "tagDemo",
244 | },
245 | Color: "carmine",
246 | },
247 | }),
248 | },
249 | nil,
250 | {
251 | language: "ja_jp",
252 | headerTitleContent: "日语环境下的主标题",
253 | headerSubtitleContent: "日语环境下的副标题",
254 | headerI18nTextTags: nil,
255 | },
256 | },
257 | }
258 |
259 | bs, err := json.Marshal(m.buildHeader())
260 | requireNoError(t, err)
261 |
262 | t.Logf("\n%s", bs)
263 |
264 | }
265 |
--------------------------------------------------------------------------------
/msg_groupbusinesscard.go:
--------------------------------------------------------------------------------
1 | package feishu_bot_api
2 |
3 | var _ Message = (*groupBusinessCard)(nil)
4 |
5 | type groupBusinessCard string
6 |
7 | func (m groupBusinessCard) Apply(body *MessageBody) error {
8 | body.MsgType = "share_chat"
9 | body.Content = &MessageBodyContent{
10 | ShareChatID: string(m),
11 | }
12 |
13 | return nil
14 | }
15 |
--------------------------------------------------------------------------------
/msg_image.go:
--------------------------------------------------------------------------------
1 | package feishu_bot_api
2 |
3 | var _ Message = (*imageMessage)(nil)
4 |
5 | type imageMessage string
6 |
7 | func (m imageMessage) Apply(body *MessageBody) error {
8 | body.MsgType = "image"
9 | body.Content = &MessageBodyContent{
10 | ImageKey: string(m),
11 | }
12 |
13 | return nil
14 | }
15 |
--------------------------------------------------------------------------------
/msg_richtext.go:
--------------------------------------------------------------------------------
1 | package feishu_bot_api
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | )
8 |
9 | var _ Message = (*richTextMessage)(nil)
10 |
11 | type richTextMessage []*RichTextBuilder
12 |
13 | func (m richTextMessage) Apply(body *MessageBody) error {
14 | raw, err := m.marshal()
15 | if err != nil {
16 | return fmt.Errorf("rich text message: %w", err)
17 | }
18 |
19 | body.MsgType = "post"
20 | body.Content = &MessageBodyContent{
21 | Post: &raw,
22 | }
23 | return nil
24 | }
25 |
26 | func (m richTextMessage) marshal() (json.RawMessage, error) {
27 | var buf bytes.Buffer
28 | buf.Grow(len(m) * 48)
29 |
30 | buf.WriteString("{")
31 |
32 | nnm := make(richTextMessage, 0, len(m))
33 | for i := range m {
34 | if m[i] == nil {
35 | continue
36 | }
37 | nnm = append(nnm, m[i])
38 | }
39 |
40 | commas := len(nnm)
41 | for _, rtb := range nnm {
42 | commas--
43 |
44 | if rtb == nil {
45 | continue
46 | }
47 |
48 | bs, err := json.Marshal(rtb.body)
49 | if err != nil {
50 | return nil, fmt.Errorf("marshal(%s): %w", rtb.language, err)
51 | }
52 | s := fmt.Sprintf(`"%s":%s`, _quoteEscaper.Replace(string(rtb.language)), bs)
53 |
54 | if commas > 0 {
55 | s += ","
56 | }
57 |
58 | buf.WriteString(s)
59 | }
60 |
61 | buf.WriteString("}")
62 |
63 | return buf.Bytes(), nil
64 | }
65 |
66 | // --------------------------------------------------------------------------------
67 |
68 | type (
69 | RichTextBuilder struct {
70 | language Language
71 | body richTextBody
72 | }
73 |
74 | richTextBody struct {
75 | Title string `json:"title,omitempty"`
76 | Content []richTextParagraph `json:"content,omitempty"`
77 | }
78 | richTextParagraph []richTextLabel
79 |
80 | richTextLabel struct {
81 | Tag string `json:"tag"`
82 | Text string `json:"text,omitempty"`
83 |
84 | // 仅 文本标签(text) 使用;表示是否 unescape 解码。默认值为 false,未用到 unescape 时可以不填
85 | UnEscape *bool `json:"un_escape,omitempty"`
86 |
87 | // 仅 超链接标签(a) 使用;链接地址,需要确保链接地址的合法性,否则消息会发送失败
88 | Href string `json:"href,omitempty"`
89 |
90 | // 仅 @ 标签(at) 使用
91 | UserID string `json:"user_id,omitempty"`
92 | // 仅 @ 标签(at) 使用
93 | UserName string `json:"user_name,omitempty"`
94 |
95 | // 仅 图片标签(img) 使用;图片的唯一标识。可通过 上传图片 接口获取 image_key
96 | // https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/image/create
97 | ImageKey string `json:"image_key,omitempty"`
98 | }
99 | )
100 |
101 | func NewRichText(language Language, title string) *RichTextBuilder {
102 | rtb := &RichTextBuilder{
103 | language: language,
104 | body: richTextBody{
105 | Title: title,
106 | Content: make([]richTextParagraph, 0, 1),
107 | },
108 | }
109 | return rtb
110 | }
111 |
112 | // Text 文本标签
113 | //
114 | // unEscape 表示是否 unescape 解码,未用到 unescape 时传入 false
115 | func (rtb *RichTextBuilder) Text(text string, unEscape bool) *RichTextBuilder {
116 | lbl := richTextLabel{
117 | Tag: "text",
118 | Text: text,
119 | UnEscape: &unEscape,
120 | }
121 | paragraph := rtb.lastParagraph()
122 | paragraph = append(paragraph, lbl)
123 | rtb.updateLastParagraph(paragraph)
124 | return rtb
125 | }
126 |
127 | // Hyperlink 超链接标签
128 | func (rtb *RichTextBuilder) Hyperlink(text, href string) *RichTextBuilder {
129 | lbl := richTextLabel{
130 | Tag: "a",
131 | Text: text,
132 | Href: href,
133 | }
134 | paragraph := rtb.lastParagraph()
135 | paragraph = append(paragraph, lbl)
136 | rtb.updateLastParagraph(paragraph)
137 | return rtb
138 | }
139 |
140 | // At @ 标签
141 | //
142 | // id: 用户的 Open ID 或 User ID
143 | //
144 | // @ 单个用户时,id 字段必须是有效值(仅支持 @ 自定义机器人所在群的群成员)
145 | // @ 所有人时,填 all (也可以使用 RichTextBuilder.AtEveryone)
146 | func (rtb *RichTextBuilder) At(id, name string) *RichTextBuilder {
147 | lbl := richTextLabel{
148 | Tag: "at",
149 | UserID: id,
150 | UserName: name,
151 | }
152 | paragraph := rtb.lastParagraph()
153 | paragraph = append(paragraph, lbl)
154 | rtb.updateLastParagraph(paragraph)
155 | return rtb
156 | }
157 |
158 | func (rtb *RichTextBuilder) AtEveryone() *RichTextBuilder {
159 | lbl := richTextLabel{
160 | Tag: "at",
161 | UserID: "all",
162 | }
163 | paragraph := rtb.lastParagraph()
164 | paragraph = append(paragraph, lbl)
165 | rtb.updateLastParagraph(paragraph)
166 | return rtb
167 | }
168 |
169 | // Image 图片标签
170 | //
171 | // 图片的唯一标识。可通过 上传图片 接口获取
172 | // https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/image/create
173 | func (rtb *RichTextBuilder) Image(imgKey string) *RichTextBuilder {
174 | lbl := richTextLabel{
175 | Tag: "img",
176 | ImageKey: imgKey,
177 | }
178 | paragraph := rtb.lastParagraph()
179 | paragraph = append(paragraph, lbl)
180 | rtb.updateLastParagraph(paragraph)
181 | return rtb
182 | }
183 |
184 | func (rtb *RichTextBuilder) lastParagraph() richTextParagraph {
185 | if len(rtb.body.Content) == 0 {
186 | rtb.body.Content = append(rtb.body.Content, make(richTextParagraph, 0, 4))
187 | }
188 |
189 | return rtb.body.Content[len(rtb.body.Content)-1]
190 | }
191 |
192 | func (rtb *RichTextBuilder) updateLastParagraph(paragraph richTextParagraph) {
193 | rtb.body.Content[len(rtb.body.Content)-1] = paragraph
194 | }
195 |
--------------------------------------------------------------------------------
/msg_text.go:
--------------------------------------------------------------------------------
1 | package feishu_bot_api
2 |
3 | import "fmt"
4 |
5 | var _ Message = (*textMessage)(nil)
6 |
7 | type textMessage string
8 |
9 | func (m textMessage) Apply(body *MessageBody) error {
10 | body.MsgType = "text"
11 | body.Content = &MessageBodyContent{
12 | Text: string(m),
13 | }
14 |
15 | return nil
16 | }
17 |
18 | // TextAtPerson @指定人
19 | //
20 | // 可填入用户的 Open ID 或 User ID,且必须是有效值(仅支持 @ 自定义机器人所在群的群成员),否则取名字展示,并不产生实际的 @ 效果
21 | //
22 | // https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot?lang=zh-CN#756b882f
23 | // https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot?lang=zh-CN#272b1dee
24 | func TextAtPerson(id, name string) string {
25 | return fmt.Sprintf(`%s`, id, name)
26 | }
27 |
28 | // TextAtEveryone @所有人
29 | //
30 | // 必须满足所在群开启 @ 所有人功能
31 | func TextAtEveryone() string {
32 | return `everyone`
33 | }
34 |
--------------------------------------------------------------------------------
/x.go:
--------------------------------------------------------------------------------
1 | package feishu_bot_api
2 |
3 | import "strings"
4 |
5 | var _quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
6 |
--------------------------------------------------------------------------------
/x_test.go:
--------------------------------------------------------------------------------
1 | package feishu_bot_api
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func requireNoError(t *testing.T, err error) {
8 | t.Helper()
9 |
10 | if err != nil {
11 | t.Fatalf("Received unexpected error:\n%+v", err)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------