├── .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![hover_text](img_v2_16d4ea4f-6cd5-48fa-97fd-25c8d4e79b0g)\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("![%s](%s)", 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 | --------------------------------------------------------------------------------