├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ ├── build_v1.yml │ └── build_v2.yml ├── .gitignore ├── .ignore ├── .markdownlint.json ├── .vimrc ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── README_zhCN.md ├── api.go ├── api_auth.go ├── api_auth_test.go ├── api_bot.go ├── api_bot_test.go ├── api_buzz_message.go ├── api_buzz_message_test.go ├── api_chat.go ├── api_chat_test.go ├── api_contact.go ├── api_contact_test.go ├── api_group.go ├── api_group_test.go ├── api_message.go ├── api_message_test.go ├── api_notification.go ├── api_notification_test.go ├── api_upload.go ├── api_upload_test.go ├── crypto.go ├── crypto_test.go ├── emoji.go ├── error.go ├── event.go ├── event_bot_added.go ├── event_bot_deleted.go ├── event_chat_disbanded.go ├── event_message_reaction_created.go ├── event_message_reaction_deleted.go ├── event_message_read.go ├── event_message_recalled.go ├── event_message_received.go ├── event_user_added.go ├── event_user_deleted.go ├── event_v1.go ├── event_v1_test.go ├── event_v2.go ├── event_v2_test.go ├── fixtures ├── test.jpg └── test.pdf ├── go.mod ├── go.sum ├── http.go ├── http_test.go ├── http_wrapper.go ├── lark.go ├── lark_test.go ├── locale.go ├── logger.go ├── logger_test.go ├── message.go ├── message_v1.go ├── message_v2.go ├── msg_buf.go ├── msg_buf_test.go ├── msg_card_builder.go ├── msg_card_builder_test.go ├── msg_post_builder.go ├── msg_post_builder_test.go ├── msg_template_builder.go ├── msg_template_builder_test.go ├── msg_text_builder.go ├── msg_text_builder_test.go ├── scripts ├── test_v1.sh └── test_v2.sh ├── user.go ├── user_test.go ├── util.go ├── util_test.go └── v2 ├── README.md ├── api.go ├── api_auth.go ├── api_auth_test.go ├── api_bot.go ├── api_bot_test.go ├── api_buzz_message.go ├── api_buzz_message_test.go ├── api_chat.go ├── api_chat_test.go ├── api_contact.go ├── api_contact_test.go ├── api_message.go ├── api_message_test.go ├── api_notification.go ├── api_notification_test.go ├── crypto.go ├── crypto_test.go ├── emoji.go ├── error.go ├── event.go ├── event_bot_added.go ├── event_bot_deleted.go ├── event_challenge.go ├── event_chat_disbanded.go ├── event_message_reaction_created.go ├── event_message_reaction_deleted.go ├── event_message_read.go ├── event_message_recalled.go ├── event_message_received.go ├── event_test.go ├── event_user_added.go ├── event_user_deleted.go ├── go.mod ├── go.sum ├── http.go ├── http_client.go ├── http_test.go ├── lark.go ├── lark_test.go ├── locale.go ├── logger.go ├── logger_test.go ├── message.go ├── message_builder.go ├── msg_buf.go ├── msg_buf_test.go ├── msg_card_builder.go ├── msg_card_builder_test.go ├── msg_post_builder.go ├── msg_post_builder_test.go ├── msg_template_builder.go ├── msg_template_builder_test.go ├── msg_text_builder.go ├── msg_text_builder_test.go ├── user.go ├── user_test.go ├── util.go └── util_test.go /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.go] 8 | charset = utf-8 9 | indent_style = tab 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: https://www.buymeacoffee.com/crispgm 2 | -------------------------------------------------------------------------------- /.github/workflows/build_v1.yml: -------------------------------------------------------------------------------- 1 | name: build_v1 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths-ignore: 7 | - 'v2/**' 8 | pull_request: 9 | paths-ignore: 10 | - 'v2/**' 11 | jobs: 12 | checks: 13 | name: run 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: checkout 17 | uses: actions/checkout@v3 18 | 19 | - name: cache 20 | uses: actions/cache@v3 21 | with: 22 | path: | 23 | ~/.cache/go-build 24 | ~/go/pkg/mod 25 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 26 | restore-keys: | 27 | ${{ runner.os }}-go- 28 | 29 | - name: setup 30 | uses: actions/setup-go@v3 31 | with: 32 | go-version: '1.13.0' 33 | 34 | - name: lint 35 | uses: morphy2k/revive-action@v2 36 | 37 | - name: build 38 | run: ./scripts/test_v1.sh 39 | env: 40 | LARK_APP_ID: ${{ secrets.LARK_APP_ID }} 41 | LARK_APP_SECRET: ${{ secrets.LARK_APP_SECRET }} 42 | LARK_USER_EMAIL: ${{ secrets.LARK_USER_EMAIL }} 43 | LARK_CHAT_ID: ${{ secrets.LARK_CHAT_ID }} 44 | LARK_OPEN_ID: ${{ secrets.LARK_OPEN_ID }} 45 | LARK_USER_ID: ${{ secrets.LARK_USER_ID }} 46 | LARK_UNION_ID: ${{ secrets.LARK_UNION_ID }} 47 | LARK_MESSAGE_ID: ${{ secrets.LARK_MESSAGE_ID }} 48 | LARK_WEBHOOK_V1: ${{ secrets.LARK_WEBHOOK_V1 }} 49 | LARK_WEBHOOK_V2: ${{ secrets.LARK_WEBHOOK_V2 }} 50 | LARK_WEBHOOK_V2_SIGNED: ${{ secrets.LARK_WEBHOOK_V2_SIGNED }} 51 | 52 | - name: codecov 53 | uses: codecov/codecov-action@v1 54 | with: 55 | token: ${{ secrets.CODECOV_TOKEN }} 56 | -------------------------------------------------------------------------------- /.github/workflows/build_v2.yml: -------------------------------------------------------------------------------- 1 | name: build_v2 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - 'v2/**' 8 | pull_request: 9 | paths: 10 | - 'v2/**' 11 | jobs: 12 | checks: 13 | name: run 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: checkout 17 | uses: actions/checkout@v3 18 | 19 | - name: cache 20 | uses: actions/cache@v3 21 | with: 22 | path: | 23 | ~/.cache/go-build 24 | ~/go/pkg/mod 25 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 26 | restore-keys: | 27 | ${{ runner.os }}-go- 28 | 29 | - name: setup 30 | uses: actions/setup-go@v3 31 | with: 32 | go-version: '1.24.0' 33 | 34 | - name: lint 35 | uses: morphy2k/revive-action@v2 36 | 37 | - name: build 38 | run: ./scripts/test_v2.sh 39 | env: 40 | LARK_APP_ID: ${{ secrets.LARK_APP_ID }} 41 | LARK_APP_SECRET: ${{ secrets.LARK_APP_SECRET }} 42 | LARK_USER_EMAIL: ${{ secrets.LARK_USER_EMAIL }} 43 | LARK_CHAT_ID: ${{ secrets.LARK_CHAT_ID }} 44 | LARK_OPEN_ID: ${{ secrets.LARK_OPEN_ID }} 45 | LARK_USER_ID: ${{ secrets.LARK_USER_ID }} 46 | LARK_UNION_ID: ${{ secrets.LARK_UNION_ID }} 47 | LARK_MESSAGE_ID: ${{ secrets.LARK_MESSAGE_ID }} 48 | LARK_WEBHOOK: ${{ secrets.LARK_WEBHOOK_V2 }} 49 | LARK_WEBHOOK_SIGNED: ${{ secrets.LARK_WEBHOOK_V2_SIGNED }} 50 | 51 | - name: codecov 52 | uses: codecov/codecov-action@v1 53 | with: 54 | token: ${{ secrets.CODECOV_TOKEN }} 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage.txt 2 | profile.out 3 | .env 4 | .idea 5 | -------------------------------------------------------------------------------- /.ignore: -------------------------------------------------------------------------------- 1 | go.mod 2 | go.sum 3 | *.md 4 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "no-inline-html": false, 3 | "line-length": false 4 | } 5 | -------------------------------------------------------------------------------- /.vimrc: -------------------------------------------------------------------------------- 1 | lua << EOF 2 | require('go').setup({ 3 | test_flags = { '-v', '-count=1' }, 4 | test_env = {GO_LARK_TEST_MODE = 'local'}, 5 | test_popup_width = 120, 6 | test_open_cmd = 'tabedit', 7 | tags_options = {'-sort'}, 8 | tags_transform = 'camelcase', 9 | }) 10 | EOF 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "go.testFlags": [ 3 | "-count=1", 4 | "-v" 5 | ], 6 | "go.testEnvVars": { 7 | "GO_LARK_TEST_MODE": "local", 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## v1.16.0 4 | 5 | - feat(base): add base error (#78) 6 | - feat(im): support buzz message (#77) 7 | 8 | ## v1.15.0 9 | 10 | - feat(im): support card template (#75) 11 | 12 | ## v1.14.1 13 | 14 | - feat(api): UploadFile support uploads binary file 15 | 16 | ## v1.14.0 17 | 18 | - refactor(im): build reply and update message with standalone methods 19 | - feat(im): UpdateMessage supports text and post in addition to card 20 | - feat(im): ReplyMessage supports reply in thread 21 | 22 | ## v1.13.3 23 | 24 | - feat(event): support message_recalled, message_reaction_created, and message_reaction_deleted 25 | 26 | ## v1.13.2 27 | 28 | - fix(event): chat_id and message_id 29 | 30 | ## v1.13.1 31 | 32 | - feat(event): support card callback (#68) 33 | 34 | ## v1.13.0 35 | 36 | - feat(contact): support user info (#67) 37 | 38 | ## v1.12.0 39 | 40 | - feat(im): support forward message 41 | - feat(im): change reaction API names (#66) 42 | 43 | ## v1.11.0 44 | 45 | - feat(im): support reactions (#62) 46 | - ci: run tests under private tenant to avoid applying permissions (#65) 47 | 48 | ## v1.10.2 49 | 50 | - fix(notification): remove chat_id and uid_type from outcoming message (#63) 51 | - fix(notification): timestamp should be string 52 | - feat(im): drop v1 update_multi 53 | 54 | ## v1.10.1 55 | 56 | - feat(chat): support chat list and chat (#60) 57 | 58 | ## v1.10.0 59 | 60 | - feat(im): support card with i18n (#59) 61 | 62 | ## v1.9.0 63 | 64 | - feat(im): support column set (#57) 65 | 66 | ## v1.8.0 67 | 68 | - feat(im): IMMessage use Sender rather than Sendor (#54) 69 | - feat(chat): set and delete notice (#53) 70 | - feat(im): pin and unpin message (#47) 71 | - fix: update chat avatar field (#46) 72 | - feat(event): add more events (#38) 73 | - fix(sign): fix missing timestamp in signed message 74 | 75 | ## v1.7.4 76 | 77 | - feat(message): support UUID (#40) 78 | 79 | ## v1.7.3 80 | 81 | - feat(notification): support sign 82 | 83 | ## v1.7.2 84 | 85 | - fix(http): remove shared context for requests 86 | - feat: improve heartbeat 87 | - feat(notification): allow update url 88 | 89 | ## v1.7.1 90 | 91 | - feat(card): support update_multi in config 92 | 93 | ## v1.7.0 94 | 95 | - feat(message): support update message card (#31) 96 | - feat(chat): add more chat APIs (#29) 97 | - feat: support event v2 (#4) 98 | - fix(chat): allow set user id type 99 | - feat: api im (#19) 100 | 101 | ## v1.6.1 102 | 103 | - docs: add extension guide [ci skip] (#18) 104 | 105 | ## v1.6.0 106 | 107 | - chore: recall message uses base response 108 | - feat: delete ephemeral message 109 | - feat: add ephemeral card 110 | - docs: add goreportcard [ci skip] 111 | 112 | ## v1.5.0 113 | 114 | - fix: div.text, option.text & tests (#13) 115 | - feat: card builder (#12) 116 | - feat(auth): make heartbeat thread safe (#10) 117 | - chore: add editorconfig [ci skip] 118 | - ci: switch to revive 119 | 120 | ## v1.4.2 121 | 122 | - feat: add domain method 123 | 124 | ## v1.4.1 125 | 126 | - fix: should pass http header 127 | - feat: add context to logger & client (#9) 128 | 129 | ## v1.3.0 130 | 131 | - refactor: better logger interface (#8) 132 | 133 | ## v1.2.1 134 | 135 | - fix: http custom client test 136 | - refactor: improve http wrapper (#7) 137 | 138 | ## v1.1.0 139 | 140 | - feat: open more api (#6) 141 | - feat(message/post): patch i18n support 142 | - docs: add godoc badge [ci skip] 143 | - ci: add codecov (#2) 144 | - chore: add .vimrc [ci skip] 145 | - feat: add alternative domains 146 | 147 | ## v1.0.1 148 | 149 | - feat: drop api user v4 150 | 151 | ## v1.0.0 152 | 153 | - feat: init go-lark/lark oss version 154 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2023 go-lark 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 | -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // BaseResponse of an API 4 | type BaseResponse struct { 5 | Code int `json:"code"` 6 | Msg string `json:"msg"` 7 | Error BaseError `json:"error"` 8 | } 9 | 10 | // BaseError returned by the platform 11 | type BaseError struct { 12 | LogID string `json:"log_id,omitempty"` 13 | } 14 | 15 | // I18NNames . 16 | type I18NNames struct { 17 | ZhCN string `json:"zh_cn,omitempty"` 18 | EnUS string `json:"en_us,omitempty"` 19 | JaJP string `json:"ja_jp,omitempty"` 20 | } 21 | 22 | // WithUserIDType . 23 | func (bot *Bot) WithUserIDType(userIDType string) *Bot { 24 | bot.userIDType = userIDType 25 | return bot 26 | } 27 | -------------------------------------------------------------------------------- /api_auth.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "sync/atomic" 5 | "time" 6 | ) 7 | 8 | // URLs for auth 9 | const ( 10 | appAccessTokenInternalURL = "/open-apis/auth/v3/app_access_token/internal" 11 | tenantAppAccessTokenInternalURL = "/open-apis/auth/v3/tenant_access_token/internal/" 12 | ) 13 | 14 | // AuthTokenInternalResponse . 15 | type AuthTokenInternalResponse struct { 16 | BaseResponse 17 | AppAccessToken string `json:"app_access_token"` 18 | Expire int `json:"expire"` 19 | } 20 | 21 | // TenantAuthTokenInternalResponse . 22 | type TenantAuthTokenInternalResponse struct { 23 | BaseResponse 24 | TenantAppAccessToken string `json:"tenant_access_token"` 25 | Expire int `json:"expire"` 26 | } 27 | 28 | // GetAccessTokenInternal gets AppAccessToken for internal use 29 | func (bot *Bot) GetAccessTokenInternal(updateToken bool) (*AuthTokenInternalResponse, error) { 30 | if !bot.requireType(ChatBot) { 31 | return nil, ErrBotTypeError 32 | } 33 | 34 | params := map[string]interface{}{ 35 | "app_id": bot.appID, 36 | "app_secret": bot.appSecret, 37 | } 38 | var respData AuthTokenInternalResponse 39 | err := bot.PostAPIRequest("GetAccessTokenInternal", appAccessTokenInternalURL, false, params, &respData) 40 | if err == nil && updateToken { 41 | bot.accessToken.Store(respData.AppAccessToken) 42 | } 43 | return &respData, err 44 | } 45 | 46 | // GetTenantAccessTokenInternal gets AppAccessToken for internal use 47 | func (bot *Bot) GetTenantAccessTokenInternal(updateToken bool) (*TenantAuthTokenInternalResponse, error) { 48 | if !bot.requireType(ChatBot) { 49 | return nil, ErrBotTypeError 50 | } 51 | 52 | params := map[string]interface{}{ 53 | "app_id": bot.appID, 54 | "app_secret": bot.appSecret, 55 | } 56 | var respData TenantAuthTokenInternalResponse 57 | err := bot.PostAPIRequest("GetTenantAccessTokenInternal", tenantAppAccessTokenInternalURL, false, params, &respData) 58 | if err == nil && updateToken { 59 | bot.tenantAccessToken.Store(respData.TenantAppAccessToken) 60 | } 61 | return &respData, err 62 | } 63 | 64 | // StopHeartbeat stop auto-renew 65 | func (bot *Bot) StopHeartbeat() { 66 | bot.heartbeat <- true 67 | } 68 | 69 | // StartHeartbeat renew auth token periodically 70 | func (bot *Bot) StartHeartbeat() error { 71 | return bot.startHeartbeat(10 * time.Second) 72 | } 73 | 74 | func (bot *Bot) startHeartbeat(defaultInterval time.Duration) error { 75 | if !bot.requireType(ChatBot) { 76 | return ErrBotTypeError 77 | } 78 | 79 | // First initialize the token in blocking mode 80 | _, err := bot.GetTenantAccessTokenInternal(true) 81 | if err != nil { 82 | bot.httpErrorLog("Heartbeat", "failed to get tenant access token", err) 83 | return err 84 | } 85 | atomic.AddInt64(&bot.heartbeatCounter, 1) 86 | 87 | interval := defaultInterval 88 | bot.heartbeat = make(chan bool) 89 | go func() { 90 | for { 91 | t := time.NewTimer(interval) 92 | select { 93 | case <-bot.heartbeat: 94 | return 95 | case <-t.C: 96 | interval = defaultInterval 97 | resp, err := bot.GetTenantAccessTokenInternal(true) 98 | if err != nil { 99 | bot.httpErrorLog("Heartbeat", "failed to get tenant access token", err) 100 | } 101 | atomic.AddInt64(&bot.heartbeatCounter, 1) 102 | if resp != nil && resp.Expire-20 > 0 { 103 | interval = time.Duration(resp.Expire-20) * time.Second 104 | } 105 | } 106 | } 107 | }() 108 | return nil 109 | } 110 | -------------------------------------------------------------------------------- /api_auth_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestAuthAccessTokenInternal(t *testing.T) { 11 | bot := newTestBot() 12 | resp, err := bot.GetAccessTokenInternal(true) 13 | if assert.NoError(t, err) { 14 | assert.Equal(t, 0, resp.Code) 15 | assert.NotEmpty(t, resp.AppAccessToken) 16 | t.Log(resp.AppAccessToken) 17 | assert.NotEmpty(t, resp.Expire) 18 | } 19 | } 20 | 21 | func TestAuthTenantAccessTokenInternal(t *testing.T) { 22 | bot := newTestBot() 23 | resp, err := bot.GetTenantAccessTokenInternal(true) 24 | if assert.NoError(t, err) { 25 | assert.Equal(t, 0, resp.Code) 26 | assert.NotEmpty(t, resp.TenantAppAccessToken) 27 | t.Log(resp.TenantAppAccessToken) 28 | assert.NotEmpty(t, resp.Expire) 29 | } 30 | } 31 | 32 | func TestHeartbeat(t *testing.T) { 33 | bot := newTestBot() 34 | assert.Nil(t, bot.heartbeat) 35 | assert.Nil(t, bot.startHeartbeat(time.Second*1)) 36 | assert.NotEmpty(t, bot.tenantAccessToken) 37 | assert.Equal(t, int64(1), bot.heartbeatCounter) 38 | time.Sleep(2 * time.Second) 39 | assert.Equal(t, int64(2), bot.heartbeatCounter) 40 | bot.StopHeartbeat() 41 | time.Sleep(2 * time.Second) 42 | assert.Equal(t, int64(2), bot.heartbeatCounter) 43 | // restart heartbeat 44 | assert.Nil(t, bot.startHeartbeat(time.Second*1)) 45 | time.Sleep(2 * time.Second) 46 | assert.Equal(t, int64(4), bot.heartbeatCounter) 47 | } 48 | 49 | func TestInvalidHeartbeat(t *testing.T) { 50 | bot := NewNotificationBot("") 51 | err := bot.StartHeartbeat() 52 | assert.Error(t, err, ErrBotTypeError) 53 | } 54 | -------------------------------------------------------------------------------- /api_bot.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | const ( 4 | getBotInfoURL = "/open-apis/bot/v3/info/" 5 | ) 6 | 7 | // GetBotInfoResponse . 8 | type GetBotInfoResponse struct { 9 | BaseResponse 10 | Bot struct { 11 | ActivateStatus int `json:"activate_status"` 12 | AppName string `json:"app_name"` 13 | AvatarURL string `json:"avatar_url"` 14 | IPWhiteList []string `json:"ip_white_list"` 15 | OpenID string `json:"open_id"` 16 | } `json:"bot"` 17 | } 18 | 19 | // GetBotInfo returns bot info 20 | func (bot *Bot) GetBotInfo() (*GetBotInfoResponse, error) { 21 | var respData GetBotInfoResponse 22 | err := bot.PostAPIRequest("GetBotInfo", getBotInfoURL, true, nil, &respData) 23 | return &respData, err 24 | } 25 | -------------------------------------------------------------------------------- /api_bot_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGetBotInfo(t *testing.T) { 10 | resp, err := bot.GetBotInfo() 11 | if assert.NoError(t, err) { 12 | assert.Equal(t, 0, resp.Code) 13 | assert.Equal(t, "go-lark-bot", resp.Bot.AppName) 14 | t.Log(resp) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /api_buzz_message.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import "fmt" 4 | 5 | const ( 6 | buzzInAppURL = "/open-apis/im/v1/messages/%s/urgent_app?user_id_type=%s" 7 | buzzSMSURL = "/open-apis/im/v1/messages/%s/urgent_sms?user_id_type=%s" 8 | buzzPhoneURL = "/open-apis/im/v1/messages/%s/urgent_phone?user_id_type=%s" 9 | ) 10 | 11 | // Buzz types 12 | const ( 13 | BuzzTypeInApp = "buzz_inapp" 14 | BuzzTypeSMS = "buzz_sms" 15 | BuzzTypePhone = "buzz_phone" 16 | ) 17 | 18 | // BuzzMessageResponse . 19 | type BuzzMessageResponse struct { 20 | BaseResponse 21 | 22 | Data struct { 23 | InvalidUserIDList []string `json:"invalid_user_id_list,omitempty"` 24 | } `json:"data,omitempty"` 25 | } 26 | 27 | // BuzzMessage . 28 | func (bot Bot) BuzzMessage(buzzType string, messageID string, userIDList ...string) (*BuzzMessageResponse, error) { 29 | var respData BuzzMessageResponse 30 | url := buzzInAppURL 31 | switch buzzType { 32 | case BuzzTypeInApp: 33 | url = buzzInAppURL 34 | case BuzzTypeSMS: 35 | url = buzzSMSURL 36 | case BuzzTypePhone: 37 | url = buzzPhoneURL 38 | } 39 | req := map[string][]string{ 40 | "user_id_list": userIDList, 41 | } 42 | err := bot.PatchAPIRequest("BuzzMessage", fmt.Sprintf(url, messageID, bot.userIDType), true, req, &respData) 43 | return &respData, err 44 | } 45 | -------------------------------------------------------------------------------- /api_buzz_message_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestBuzzMessage(t *testing.T) { 10 | resp, err := bot.PostText("this text will be buzzed", WithEmail(testUserEmail)) 11 | if assert.NoError(t, err) { 12 | bot.WithUserIDType(UIDOpenID) 13 | messageID := resp.Data.MessageID 14 | buzzResp, err := bot.BuzzMessage(BuzzTypeInApp, messageID, testUserOpenID) 15 | if assert.NoError(t, err) { 16 | assert.Equal(t, 0, buzzResp.Code) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /api_chat_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestChatInfo(t *testing.T) { 13 | bot.WithUserIDType(UIDOpenID) 14 | assert.Equal(t, UIDOpenID, bot.userIDType) 15 | resp, err := bot.GetChat(testGroupChatID) 16 | if assert.NoError(t, err) { 17 | assert.Equal(t, 0, resp.Code) 18 | assert.Equal(t, "go-lark-ci", resp.Data.Name) 19 | assert.Equal(t, "group", resp.Data.ChatMode) 20 | assert.Equal(t, testUserOpenID, resp.Data.OwnerID) 21 | t.Log(resp.Data) 22 | } 23 | } 24 | 25 | func TestChatList(t *testing.T) { 26 | bot.WithUserIDType(UIDOpenID) 27 | assert.Equal(t, UIDOpenID, bot.userIDType) 28 | resp, err := bot.ListChat("ByCreateTimeAsc", "", 10) 29 | if assert.NoError(t, err) { 30 | assert.Equal(t, 0, resp.Code) 31 | assert.NotEmpty(t, resp.Data.Items) 32 | t.Log(resp.Data.Items[0]) 33 | } 34 | } 35 | 36 | func TestChatSearch(t *testing.T) { 37 | bot.WithUserIDType(UIDOpenID) 38 | assert.Equal(t, UIDOpenID, bot.userIDType) 39 | resp, err := bot.SearchChat("go-lark", "", 10) 40 | if assert.NoError(t, err) { 41 | assert.Equal(t, 0, resp.Code) 42 | if assert.NotEmpty(t, resp.Data.Items) { 43 | for _, item := range resp.Data.Items { 44 | if !strings.Contains(item.Name, "go-lark") { 45 | t.Error(item.Name, "does not contain go-lark") 46 | } 47 | } 48 | } 49 | t.Log(resp.Data.Items) 50 | } 51 | } 52 | 53 | func TestChatCRUD(t *testing.T) { 54 | bot.WithUserIDType(UIDOpenID) 55 | resp, err := bot.CreateChat(CreateChatRequest{ 56 | Name: fmt.Sprintf("go-lark-ci-%d", time.Now().Unix()), 57 | ChatMode: "group", 58 | ChatType: "public", 59 | }) 60 | if assert.NoError(t, err) { 61 | chatID := resp.Data.ChatID 62 | assert.NotEmpty(t, chatID) 63 | upResp, err := bot.UpdateChat(chatID, UpdateChatRequest{ 64 | Description: "new description", 65 | }) 66 | t.Log(upResp) 67 | if assert.NoError(t, err) { 68 | getResp, err := bot.GetChat(chatID) 69 | if assert.NoError(t, err) { 70 | assert.Equal(t, "new description", getResp.Data.Description) 71 | // join chat 72 | joinResp, err := bot.JoinChat(chatID) 73 | assert.Zero(t, joinResp.Code) 74 | assert.NoError(t, err) 75 | 76 | // add chat member 77 | addMemberResp, err := bot.AddChatMember(chatID, []string{testUserOpenID}) 78 | if assert.NoError(t, err) { 79 | assert.Equal(t, 0, addMemberResp.Code) 80 | assert.Empty(t, addMemberResp.Data.InvalidIDList) 81 | } 82 | // remove chat member 83 | removeMemberResp, err := bot.RemoveChatMember(chatID, []string{testUserOpenID}) 84 | if assert.NoError(t, err) { 85 | assert.Equal(t, 0, removeMemberResp.Code) 86 | assert.Empty(t, removeMemberResp.Data.InvalidIDList) 87 | } 88 | 89 | // delete 90 | _, err = bot.DeleteChat(chatID) 91 | assert.NoError(t, err) 92 | } 93 | } 94 | } 95 | } 96 | 97 | func TestIsInChat(t *testing.T) { 98 | resp, err := bot.IsInChat(testGroupChatID) 99 | if assert.NoError(t, err) { 100 | assert.Equal(t, 0, resp.Code) 101 | assert.True(t, resp.Data.IsInChat) 102 | } 103 | } 104 | 105 | func TestGetChatMembers(t *testing.T) { 106 | bot.WithUserIDType(UIDOpenID) 107 | resp, err := bot.GetChatMembers(testGroupChatID, "", 1) 108 | if assert.NoError(t, err) { 109 | assert.Equal(t, 0, resp.Code) 110 | assert.NotEmpty(t, resp.Data.Items) 111 | assert.Empty(t, resp.Data.PageToken) 112 | assert.NotEmpty(t, resp.Data.MemberTotal) 113 | assert.False(t, resp.Data.HasMore) 114 | } 115 | } 116 | 117 | func TestChatTopNotice(t *testing.T) { 118 | resp, err := bot.PostText("group notice", WithChatID(testGroupChatID)) 119 | if assert.NoError(t, err) { 120 | setResp, _ := bot.SetTopNotice(testGroupChatID, "2", resp.Data.MessageID) 121 | assert.Equal(t, 0, setResp.Code) 122 | delResp, _ := bot.DeleteTopNotice(testGroupChatID) 123 | assert.Equal(t, 0, delResp.Code) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /api_contact.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | ) 7 | 8 | const ( 9 | getUserInfoURL = "/open-apis/contact/v3/users/%s?user_id_type=%s" 10 | batchGetUserInfoURL = "/open-apis/contact/v3/users/batch?%s" 11 | ) 12 | 13 | // GetUserInfoResponse . 14 | type GetUserInfoResponse struct { 15 | BaseResponse 16 | Data struct { 17 | User UserInfo 18 | } 19 | } 20 | 21 | // BatchGetUserInfoResponse . 22 | type BatchGetUserInfoResponse struct { 23 | BaseResponse 24 | Data struct { 25 | Items []UserInfo 26 | } 27 | } 28 | 29 | // UserInfo . 30 | type UserInfo struct { 31 | OpenID string `json:"open_id,omitempty"` 32 | Email string `json:"email,omitempty"` 33 | UserID string `json:"user_id,omitempty"` 34 | ChatID string `json:"chat_id,omitempty"` 35 | UnionID string `json:"union_id,omitempty"` 36 | Name string `json:"name,omitempty"` 37 | EnglishName string `json:"en_name,omitempty"` 38 | NickName string `json:"nickname,omitempty"` 39 | Mobile string `json:"mobile,omitempty"` 40 | MobileVisible bool `json:"mobile_visible,omitempty"` 41 | Gender int `json:"gender,omitempty"` 42 | Avatar UserAvatar `json:"avatar,omitempty"` 43 | Status UserStatus `json:"status,omitempty"` 44 | City string `json:"city,omitempty"` 45 | Country string `json:"country,omitempty"` 46 | WorkStation string `json:"work_station,omitempty"` 47 | JoinTime int `json:"join_time,omitempty"` 48 | EmployeeNo string `json:"employee_no,omitempty"` 49 | EmployeeType int `json:"employee_type,omitempty"` 50 | EnterpriseEmail string `json:"enterprise_email,omitempty"` 51 | Geo string `json:"geo,omitempty"` 52 | JobTitle string `json:"job_title,omitempty"` 53 | JobLevelID string `json:"job_level_id,omitempty"` 54 | JobFamilyID string `json:"job_family_id,omitempty"` 55 | DepartmentIDs []string `json:"department_ids,omitempty"` 56 | LeaderUserID string `json:"leader_user_id,omitempty"` 57 | IsTenantManager bool `json:"is_tenant_manager,omitempty"` 58 | } 59 | 60 | // UserAvatar . 61 | type UserAvatar struct { 62 | Avatar72 string `json:"avatar_72,omitempty"` 63 | Avatar240 string `json:"avatar_240,omitempty"` 64 | Avatar640 string `json:"avatar_640,omitempty"` 65 | AvatarOrigin string `json:"avatar_origin,omitempty"` 66 | } 67 | 68 | // UserStatus . 69 | type UserStatus struct { 70 | IsFrozen bool 71 | IsResigned bool 72 | IsActivated bool 73 | IsExited bool 74 | IsUnjoin bool 75 | } 76 | 77 | // GetUserInfo gets contact info 78 | func (bot Bot) GetUserInfo(userID *OptionalUserID) (*GetUserInfoResponse, error) { 79 | url := fmt.Sprintf(getUserInfoURL, userID.RealID, userID.UIDType) 80 | var respData GetUserInfoResponse 81 | err := bot.GetAPIRequest("GetUserInfo", url, true, nil, &respData) 82 | return &respData, err 83 | } 84 | 85 | // BatchGetUserInfo gets contact info in batch 86 | func (bot Bot) BatchGetUserInfo(userIDType string, userIDs ...string) (*BatchGetUserInfoResponse, error) { 87 | if len(userIDs) == 0 || len(userIDs) > 50 { 88 | return nil, ErrParamExceedInputLimit 89 | } 90 | v := url.Values{} 91 | v.Set("user_id_type", userIDType) 92 | for _, userID := range userIDs { 93 | v.Add("user_ids", userID) 94 | } 95 | url := fmt.Sprintf(batchGetUserInfoURL, v.Encode()) 96 | var respData BatchGetUserInfoResponse 97 | err := bot.GetAPIRequest("GetUserInfo", url, true, nil, &respData) 98 | return &respData, err 99 | } 100 | -------------------------------------------------------------------------------- /api_contact_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGetUserInfo(t *testing.T) { 10 | resp, err := bot.GetUserInfo(WithUserID(testUserID)) 11 | if assert.NoError(t, err) { 12 | assert.Equal(t, resp.Data.User.Name, "David") 13 | } 14 | bresp, err := bot.BatchGetUserInfo(UIDUserID, testUserID) 15 | if assert.NoError(t, err) { 16 | if assert.NotEmpty(t, bresp.Data.Items) { 17 | assert.Equal(t, bresp.Data.Items[0].Name, "David") 18 | } 19 | } 20 | _, err = bot.BatchGetUserInfo(UIDUserID) 21 | assert.ErrorIs(t, err, ErrParamExceedInputLimit) 22 | } 23 | -------------------------------------------------------------------------------- /api_group_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestGetGroupList(t *testing.T) { 12 | resp, err := bot.GetGroupList(1, 10) 13 | if assert.NoError(t, err) { 14 | assert.Equal(t, 0, resp.Code) 15 | t.Log(resp.Chats) 16 | assert.NotEmpty(t, resp.Chats) 17 | } 18 | } 19 | 20 | func TestGetGroupInfo(t *testing.T) { 21 | resp, err := bot.GetGroupInfo(testGroupChatID) 22 | if assert.NoError(t, err) { 23 | assert.Equal(t, 0, resp.Code) 24 | assert.Equal(t, "go-lark-ci", resp.Data.Name) 25 | assert.NotEmpty(t, resp.Data.Members) 26 | } 27 | } 28 | 29 | func TestGroupCRUD(t *testing.T) { 30 | // create group 31 | createResp, err := bot.CreateGroup( 32 | fmt.Sprintf("go-lark-ci group %d", time.Now().Unix()), 33 | "group create", 34 | []string{testUserOpenID}, 35 | ) 36 | if assert.NoError(t, err) { 37 | assert.Equal(t, 0, createResp.Code) 38 | } 39 | groupChatID := createResp.OpenChatID 40 | // delete member 41 | delResp, err := bot.DeleteGroupMember(groupChatID, []string{testUserOpenID}) 42 | if assert.NoError(t, err) { 43 | assert.Equal(t, 0, delResp.Code) 44 | } 45 | // add member 46 | addResp, err := bot.AddGroupMember(groupChatID, []string{testUserOpenID}) 47 | if assert.NoError(t, err) { 48 | assert.Equal(t, 0, addResp.Code) 49 | } 50 | // delete again 51 | delResp, err = bot.DeleteGroupMember(groupChatID, []string{testUserOpenID}) 52 | if assert.NoError(t, err) { 53 | assert.Equal(t, 0, delResp.Code) 54 | } 55 | // add by user id 56 | addByUserResp, err := bot.AddGroupMemberByUserID(groupChatID, []string{testUserID}) 57 | if assert.NoError(t, err) { 58 | assert.Equal(t, 0, addByUserResp.Code) 59 | } 60 | // update info 61 | updateResp, err := bot.UpdateGroupInfo(&UpdateGroupInfoReq{ 62 | OpenChatID: groupChatID, 63 | Name: "test 1", 64 | }) 65 | if assert.NoError(t, err) { 66 | assert.Equal(t, 0, updateResp.Code) 67 | } 68 | // disband 69 | disbandResp, err := bot.DisbandGroup(groupChatID) 70 | if assert.NoError(t, err) { 71 | assert.Equal(t, 0, disbandResp.Code) 72 | } 73 | } 74 | 75 | func TestBotAddRemove(t *testing.T) { 76 | // rm bot 77 | rmBotResp, err := bot.RemoveBotFromGroup(testGroupChatID) 78 | if assert.NoError(t, err) { 79 | assert.Equal(t, 0, rmBotResp.Code) 80 | } 81 | // add bot 82 | addBotResp, err := bot.AddBotToGroup(testGroupChatID) 83 | if assert.NoError(t, err) { 84 | assert.Equal(t, 0, addBotResp.Code) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /api_notification.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // PostNotificationResp response of PostNotification 4 | type PostNotificationResp struct { 5 | Ok bool `json:"ok,omitempty"` 6 | } 7 | 8 | // PostNotificationV2Resp response of PostNotificationV2 9 | type PostNotificationV2Resp struct { 10 | Code int `json:"code"` 11 | Msg string `json:"msg"` 12 | StatusCode int `json:"StatusCode"` 13 | StatusMessage string `json:"StatusMessage"` 14 | } 15 | 16 | // PostNotification send message to a webhook 17 | // deprecated: legacy version, please use PostNotificationV2 instead 18 | func (bot *Bot) PostNotification(title, text string) (*PostNotificationResp, error) { 19 | if !bot.requireType(NotificationBot) { 20 | return nil, ErrBotTypeError 21 | } 22 | 23 | params := map[string]interface{}{ 24 | "title": title, 25 | "text": text, 26 | } 27 | var respData PostNotificationResp 28 | err := bot.PostAPIRequest("PostNotification", bot.webhook, false, params, &respData) 29 | return &respData, err 30 | } 31 | 32 | // PostNotificationV2 support v2 format 33 | func (bot *Bot) PostNotificationV2(om OutcomingMessage) (*PostNotificationV2Resp, error) { 34 | if !bot.requireType(NotificationBot) { 35 | return nil, ErrBotTypeError 36 | } 37 | 38 | params := buildOutcomingNotification(om) 39 | var respData PostNotificationV2Resp 40 | err := bot.PostAPIRequest("PostNotificationV2", bot.webhook, false, params, &respData) 41 | return &respData, err 42 | } 43 | -------------------------------------------------------------------------------- /api_notification_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | // A weird case which sends V2 message body with V1 URL 11 | func TestWebhookV1Error(t *testing.T) { 12 | bot := NewNotificationBot(testWebhookV1) 13 | mbText := NewMsgBuffer(MsgText) 14 | mbText.Text("hello") 15 | resp, err := bot.PostNotificationV2(mbText.Build()) 16 | assert.NoError(t, err) 17 | assert.Zero(t, resp.StatusCode) 18 | } 19 | 20 | func TestWebhookV2(t *testing.T) { 21 | bot := NewNotificationBot(testWebhookV2) 22 | 23 | mbText := NewMsgBuffer(MsgText) 24 | mbText.Text("hello") 25 | resp, err := bot.PostNotificationV2(mbText.Build()) 26 | assert.NoError(t, err) 27 | assert.Zero(t, resp.StatusCode) 28 | assert.Equal(t, "success", resp.StatusMessage) 29 | 30 | mbPost := NewMsgBuffer(MsgPost) 31 | mbPost.Post(NewPostBuilder().Title("hello").TextTag("world", 1, true).Render()) 32 | resp, err = bot.PostNotificationV2(mbPost.Build()) 33 | assert.NoError(t, err) 34 | assert.Zero(t, resp.StatusCode) 35 | assert.Equal(t, "success", resp.StatusMessage) 36 | 37 | mbImg := NewMsgBuffer(MsgImage) 38 | mbImg.Image("img_a97c1597-9c0a-47c1-9fb4-dd3e5e37ac9g") 39 | resp, err = bot.PostNotificationV2(mbImg.Build()) 40 | assert.NoError(t, err) 41 | assert.Zero(t, resp.StatusCode) 42 | assert.Equal(t, "success", resp.StatusMessage) 43 | 44 | mbShareGroup := NewMsgBuffer(MsgShareCard) 45 | mbShareGroup.ShareChat(testGroupChatID) 46 | resp, err = bot.PostNotificationV2(mbShareGroup.Build()) 47 | assert.NoError(t, err) 48 | assert.Zero(t, resp.StatusCode) 49 | assert.Equal(t, "success", resp.StatusMessage) 50 | } 51 | 52 | func TestWebhookV2CardMessage(t *testing.T) { 53 | bot := NewNotificationBot(testWebhookV2) 54 | 55 | b := NewCardBuilder() 56 | card := b.Card( 57 | b.Div( 58 | b.Field(b.Text("左侧内容")).Short(), 59 | b.Field(b.Text("右侧内容")).Short(), 60 | b.Field(b.Text("整排内容")), 61 | b.Field(b.Text("整排**Markdown**内容").LarkMd()), 62 | ), 63 | b.Div(). 64 | Text(b.Text("Text Content")). 65 | Extra(b.Img("img_a7c6aa35-382a-48ad-839d-d0182a69b4dg")), 66 | b.Note(). 67 | AddText(b.Text("Note **Text**").LarkMd()). 68 | AddImage(b.Img("img_a7c6aa35-382a-48ad-839d-d0182a69b4dg")), 69 | ). 70 | Wathet(). 71 | Title("Notification Card") 72 | msgV4 := NewMsgBuffer(MsgInteractive) 73 | omV4 := msgV4.Card(card.String()).Build() 74 | resp, err := bot.PostNotificationV2(omV4) 75 | 76 | if assert.NoError(t, err) { 77 | assert.Equal(t, 0, resp.StatusCode) 78 | assert.NotEmpty(t, resp.StatusMessage) 79 | } 80 | } 81 | 82 | func TestWebhookV2Signed(t *testing.T) { 83 | bot := NewNotificationBot(testWebhookV2Signed) 84 | 85 | mbText := NewMsgBuffer(MsgText) 86 | mbText.Text("hello sign").WithSign("FT1dnAgPYYTcpafMTkhPjc", time.Now().Unix()) 87 | resp, err := bot.PostNotificationV2(mbText.Build()) 88 | assert.NoError(t, err) 89 | assert.Zero(t, resp.StatusCode) 90 | assert.Equal(t, "success", resp.StatusMessage) 91 | } 92 | 93 | func TestWebhookV2SignedError(t *testing.T) { 94 | bot := NewNotificationBot("https://open.feishu.cn/open-apis/bot/v2/hook/749be902-6eaa-4cc3-9325-be4126164b02") 95 | 96 | mbText := NewMsgBuffer(MsgText) 97 | mbText.Text("hello sign").WithSign("LIpnNexV7rwOyOebKoqSdb", time.Now().Unix()) 98 | resp, err := bot.PostNotificationV2(mbText.Build()) 99 | assert.NoError(t, err) 100 | assert.Zero(t, resp.StatusCode) 101 | assert.Equal(t, "sign match fail or timestamp is not within one hour from current time", resp.Msg) 102 | } 103 | -------------------------------------------------------------------------------- /api_upload.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // Import from Lark API Go demo 4 | // with adaption to go-lark frame 5 | 6 | import ( 7 | "bytes" 8 | "fmt" 9 | "image" 10 | "image/jpeg" 11 | "io" 12 | "mime/multipart" 13 | "net/http" 14 | "os" 15 | ) 16 | 17 | const ( 18 | uploadImageURL = "/open-apis/im/v1/images" 19 | uploadFileURL = "/open-apis/im/v1/files" 20 | ) 21 | 22 | // UploadImageResponse . 23 | type UploadImageResponse struct { 24 | BaseResponse 25 | Data struct { 26 | ImageKey string `json:"image_key"` 27 | } `json:"data"` 28 | } 29 | 30 | // UploadFileRequest . 31 | type UploadFileRequest struct { 32 | FileType string `json:"-"` 33 | FileName string `json:"-"` 34 | Duration int `json:"-"` 35 | Path string `json:"-"` 36 | Reader io.Reader `json:"-"` 37 | } 38 | 39 | // UploadFileResponse . 40 | type UploadFileResponse struct { 41 | BaseResponse 42 | Data struct { 43 | FileKey string `json:"file_key"` 44 | } `json:"data"` 45 | } 46 | 47 | // UploadImage uploads image to Lark server 48 | func (bot Bot) UploadImage(path string) (*UploadImageResponse, error) { 49 | file, err := os.Open(path) 50 | if err != nil { 51 | return nil, err 52 | } 53 | defer file.Close() 54 | 55 | body := &bytes.Buffer{} 56 | writer := multipart.NewWriter(body) 57 | writer.WriteField("image_type", "message") 58 | part, err := writer.CreateFormFile("image", path) 59 | if err != nil { 60 | return nil, err 61 | } 62 | _, err = io.Copy(part, file) 63 | if err != nil { 64 | return nil, err 65 | } 66 | err = writer.Close() 67 | if err != nil { 68 | return nil, err 69 | } 70 | var respData UploadImageResponse 71 | header := make(http.Header) 72 | header.Set("Content-Type", writer.FormDataContentType()) 73 | err = bot.DoAPIRequest("POST", "UploadImage", uploadImageURL, header, true, body, &respData) 74 | if err != nil { 75 | return nil, err 76 | } 77 | return &respData, err 78 | } 79 | 80 | // UploadImageObject uploads image to Lark server 81 | func (bot Bot) UploadImageObject(img image.Image) (*UploadImageResponse, error) { 82 | body := &bytes.Buffer{} 83 | writer := multipart.NewWriter(body) 84 | writer.WriteField("image_type", "message") 85 | part, err := writer.CreateFormFile("image", "temp_image") 86 | if err != nil { 87 | return nil, err 88 | } 89 | err = jpeg.Encode(part, img, nil) 90 | if err != nil { 91 | return nil, err 92 | } 93 | err = writer.Close() 94 | if err != nil { 95 | return nil, err 96 | } 97 | var respData UploadImageResponse 98 | header := make(http.Header) 99 | header.Set("Content-Type", writer.FormDataContentType()) 100 | err = bot.DoAPIRequest("POST", "UploadImage", uploadImageURL, header, true, body, &respData) 101 | if err != nil { 102 | return nil, err 103 | } 104 | return &respData, err 105 | } 106 | 107 | // UploadFile uploads file to Lark server 108 | func (bot Bot) UploadFile(req UploadFileRequest) (*UploadFileResponse, error) { 109 | var content io.Reader 110 | if req.Reader == nil { 111 | file, err := os.Open(req.Path) 112 | if err != nil { 113 | return nil, err 114 | } 115 | defer file.Close() 116 | content = file 117 | } else { 118 | content = req.Reader 119 | req.Path = req.FileName 120 | } 121 | 122 | body := &bytes.Buffer{} 123 | writer := multipart.NewWriter(body) 124 | writer.WriteField("file_type", req.FileType) 125 | writer.WriteField("file_name", req.FileName) 126 | if req.FileType == "mp4" && req.Duration > 0 { 127 | writer.WriteField("duration", fmt.Sprintf("%d", req.Duration)) 128 | } 129 | part, err := writer.CreateFormFile("file", req.Path) 130 | if err != nil { 131 | return nil, err 132 | } 133 | _, err = io.Copy(part, content) 134 | if err != nil { 135 | return nil, err 136 | } 137 | err = writer.Close() 138 | if err != nil { 139 | return nil, err 140 | } 141 | var respData UploadFileResponse 142 | header := make(http.Header) 143 | header.Set("Content-Type", writer.FormDataContentType()) 144 | err = bot.DoAPIRequest("POST", "UploadFile", uploadFileURL, header, true, body, &respData) 145 | if err != nil { 146 | return nil, err 147 | } 148 | return &respData, err 149 | } 150 | -------------------------------------------------------------------------------- /api_upload_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "image/jpeg" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestUploadImage(t *testing.T) { 13 | resp, err := bot.UploadImage("./fixtures/test.jpg") 14 | if assert.NoError(t, err) { 15 | assert.Zero(t, resp.Code) 16 | t.Log(resp.Data.ImageKey) 17 | assert.NotEmpty(t, resp.Data.ImageKey) 18 | } 19 | } 20 | 21 | func TestUploadImageObject(t *testing.T) { 22 | file, _ := os.Open("./fixtures/test.jpg") 23 | img, _ := jpeg.Decode(file) 24 | 25 | resp, err := bot.UploadImageObject(img) 26 | if assert.NoError(t, err) { 27 | assert.Zero(t, resp.Code) 28 | t.Log(resp.Data.ImageKey) 29 | assert.NotEmpty(t, resp.Data.ImageKey) 30 | } 31 | } 32 | 33 | func TestUploadFile(t *testing.T) { 34 | resp, err := bot.UploadFile(UploadFileRequest{ 35 | FileType: "pdf", 36 | FileName: "hello.pdf", 37 | Path: "./fixtures/test.pdf", 38 | }) 39 | if assert.NoError(t, err) { 40 | assert.Zero(t, resp.Code) 41 | t.Log(resp.Data.FileKey) 42 | assert.NotEmpty(t, resp.Data.FileKey) 43 | } 44 | } 45 | 46 | func TestUploadFile_Binary(t *testing.T) { 47 | resp, err := bot.UploadFile(UploadFileRequest{ 48 | FileType: "stream", 49 | FileName: "test-data.csv", 50 | Reader: strings.NewReader(`Name,Age,Location 51 | Foo,25,Sleman 52 | Bar,23,Sidoarjo 53 | Baz,27,Bantul`), 54 | }) 55 | if assert.NoError(t, err) { 56 | assert.Zero(t, resp.Code) 57 | t.Log(resp.Data.FileKey) 58 | assert.NotEmpty(t, resp.Data.FileKey) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /crypto.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/hmac" 7 | "crypto/sha256" 8 | "encoding/base64" 9 | "errors" 10 | "fmt" 11 | ) 12 | 13 | // EncryptKey . 14 | func EncryptKey(key string) []byte { 15 | sha256key := sha256.Sum256([]byte(key)) 16 | return sha256key[:sha256.Size] 17 | } 18 | 19 | // Decrypt with AES Cipher 20 | func Decrypt(encryptedKey []byte, data string) ([]byte, error) { 21 | block, err := aes.NewCipher(encryptedKey) 22 | if err != nil { 23 | return nil, err 24 | } 25 | ciphertext, err := base64.StdEncoding.DecodeString(data) 26 | iv := encryptedKey[:aes.BlockSize] 27 | blockMode := cipher.NewCBCDecrypter(block, iv) 28 | decryptedData := make([]byte, len(data)) 29 | blockMode.CryptBlocks(decryptedData, ciphertext) 30 | msg := unpad(decryptedData) 31 | if len(msg) < block.BlockSize() { 32 | return nil, errors.New("msg length is less than blocksize") 33 | } 34 | return msg[block.BlockSize():], err 35 | } 36 | 37 | func unpad(data []byte) []byte { 38 | length := len(data) 39 | var unpadding, unpaddingIdx int 40 | for i := length - 1; i > 0; i-- { 41 | if data[i] != 0 { 42 | unpadding = int(data[i]) 43 | unpaddingIdx = length - 1 - i 44 | break 45 | } 46 | } 47 | return data[:(length - unpaddingIdx - unpadding)] 48 | } 49 | 50 | // GenSign generate sign for notification bot 51 | func GenSign(secret string, timestamp int64) (string, error) { 52 | stringToSign := fmt.Sprintf("%v", timestamp) + "\n" + secret 53 | 54 | var data []byte 55 | h := hmac.New(sha256.New, []byte(stringToSign)) 56 | _, err := h.Write(data) 57 | if err != nil { 58 | return "", err 59 | } 60 | 61 | signature := base64.StdEncoding.EncodeToString(h.Sum(nil)) 62 | return signature, nil 63 | } 64 | -------------------------------------------------------------------------------- /crypto_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestAESDecrypt(t *testing.T) { 10 | key := EncryptKey("test key") 11 | data, _ := Decrypt(key, "P37w+VZImNgPEO1RBhJ6RtKl7n6zymIbEG1pReEzghk=") 12 | assert.Equal(t, "hello world", string(data)) 13 | assert.Equal(t, 11, len(data)) 14 | } 15 | 16 | func TestGenSign(t *testing.T) { 17 | sign, err := GenSign("xxx", 1661860880) 18 | if assert.NoError(t, err) { 19 | assert.Equal(t, "QnWVTSBe6FmQDE0bG6X0mURbI+DnvVyu1h+j5dHOjrU=", sign) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import "errors" 4 | 5 | // Errors 6 | var ( 7 | ErrBotTypeError = errors.New("Bot type error") 8 | ErrParamUserID = errors.New("Param error: UserID") 9 | ErrParamMessageID = errors.New("Param error: Message ID") 10 | ErrParamExceedInputLimit = errors.New("Param error: Exceed input limit") 11 | ErrMessageTypeNotSuppored = errors.New("Message type not supported") 12 | ErrEncryptionNotEnabled = errors.New("Encryption is not enabled") 13 | ErrCustomHTTPClientNotSet = errors.New("Custom HTTP client not set") 14 | ErrMessageNotBuild = errors.New("Message not build") 15 | ErrUnsupportedUIDType = errors.New("Unsupported UID type") 16 | ErrInvalidReceiveID = errors.New("Invalid receive ID") 17 | ErrEventTypeNotMatch = errors.New("Event type not match") 18 | ErrMessageType = errors.New("Message type error") 19 | ) 20 | -------------------------------------------------------------------------------- /event.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import "encoding/json" 4 | 5 | // EventChallenge request of add event hook 6 | type EventChallenge struct { 7 | Token string `json:"token,omitempty"` 8 | Challenge string `json:"challenge,omitempty"` 9 | Type string `json:"type,omitempty"` 10 | } 11 | 12 | // EventChallengeReq is deprecated. Keep for legacy versions. 13 | type EventChallengeReq = EventChallenge 14 | 15 | // EncryptedReq request of encrypted challenge 16 | type EncryptedReq struct { 17 | Encrypt string `json:"encrypt,omitempty"` 18 | } 19 | 20 | // EventCardCallback request of card 21 | type EventCardCallback struct { 22 | AppID string `json:"app_id,omitempty"` 23 | TenantKey string `json:"tenant_key,omitempty"` 24 | Token string `json:"token,omitempty"` 25 | OpenID string `json:"open_id,omitempty"` 26 | UserID string `json:"user_id,omitempty"` 27 | MessageID string `json:"open_message_id,omitempty"` 28 | ChatID string `json:"open_chat_id,omitempty"` 29 | Action EventCardAction `json:"action,omitempty"` 30 | } 31 | 32 | // EventCardAction . 33 | type EventCardAction struct { 34 | Tag string `json:"tag,omitempty"` // button, overflow, select_static, select_person, &datepicker 35 | Option string `json:"option,omitempty"` // only for Overflow and SelectMenu 36 | Timezone string `json:"timezone,omitempty"` // only for DatePicker 37 | Value json.RawMessage `json:"value,omitempty"` // for any elements with value 38 | } 39 | -------------------------------------------------------------------------------- /event_bot_added.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // EventV2BotAdded . 4 | type EventV2BotAdded struct { 5 | ChatID string `json:"chat_id,omitempty"` 6 | OperatorID EventV2UserID `json:"operator_id,omitempty"` 7 | External bool `json:"external,omitempty"` 8 | OperatorTenantKey string `json:"operator_tenant_key,omitempty"` 9 | } 10 | 11 | // GetBotAdded . 12 | func (e EventV2) GetBotAdded() (*EventV2BotAdded, error) { 13 | var body EventV2BotAdded 14 | err := e.GetEvent(EventTypeBotAdded, &body) 15 | return &body, err 16 | } 17 | -------------------------------------------------------------------------------- /event_bot_deleted.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // EventV2BotDeleted . 4 | type EventV2BotDeleted = EventV2BotAdded 5 | 6 | // GetBotDeleted . 7 | func (e EventV2) GetBotDeleted() (*EventV2BotDeleted, error) { 8 | var body EventV2BotDeleted 9 | err := e.GetEvent(EventTypeBotDeleted, &body) 10 | return &body, err 11 | } 12 | -------------------------------------------------------------------------------- /event_chat_disbanded.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // EventV2ChatDisbanded . 4 | type EventV2ChatDisbanded struct { 5 | ChatID string `json:"chat_id,omitempty"` 6 | OperatorID EventV2UserID `json:"operator_id,omitempty"` 7 | External bool `json:"external,omitempty"` 8 | OperatorTenantKey string `json:"operator_tenant_key,omitempty"` 9 | } 10 | 11 | // GetChatDisbanded . 12 | func (e EventV2) GetChatDisbanded() (*EventV2ChatDisbanded, error) { 13 | var body EventV2ChatDisbanded 14 | err := e.GetEvent(EventTypeChatDisbanded, &body) 15 | return &body, err 16 | } 17 | -------------------------------------------------------------------------------- /event_message_reaction_created.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // EventV2MessageReactionCreated . 4 | type EventV2MessageReactionCreated struct { 5 | MessageID string `json:"message_id,omitempty"` 6 | OperatorType string `json:"operator_type,omitempty"` 7 | UserID EventV2UserID `json:"user_id,omitempty"` 8 | AppID string `json:"app_id,omitempty"` 9 | ActionTime string `json:"action_time,omitempty"` 10 | ReactionType struct { 11 | EmojiType string `json:"emoji_type,omitempty"` 12 | } `json:"reaction_type,omitempty"` 13 | } 14 | 15 | // GetMessageReactionCreated . 16 | func (e EventV2) GetMessageReactionCreated() (*EventV2MessageReactionCreated, error) { 17 | var body EventV2MessageReactionCreated 18 | err := e.GetEvent(EventTypeMessageReactionCreated, &body) 19 | return &body, err 20 | } 21 | -------------------------------------------------------------------------------- /event_message_reaction_deleted.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // EventV2MessageReactionDeleted . 4 | type EventV2MessageReactionDeleted struct { 5 | MessageID string `json:"message_id,omitempty"` 6 | OperatorType string `json:"operator_type,omitempty"` 7 | UserID EventV2UserID `json:"user_id,omitempty"` 8 | AppID string `json:"app_id,omitempty"` 9 | ActionTime string `json:"action_time,omitempty"` 10 | ReactionType struct { 11 | EmojiType string `json:"emoji_type,omitempty"` 12 | } `json:"reaction_type,omitempty"` 13 | } 14 | 15 | // GetMessageReactionDeleted . 16 | func (e EventV2) GetMessageReactionDeleted() (*EventV2MessageReactionDeleted, error) { 17 | var body EventV2MessageReactionDeleted 18 | err := e.GetEvent(EventTypeMessageReactionDeleted, &body) 19 | return &body, err 20 | } 21 | -------------------------------------------------------------------------------- /event_message_read.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // EventV2MessageRead . 4 | type EventV2MessageRead struct { 5 | Reader struct { 6 | ReaderID EventV2UserID `json:"reader_id,omitempty"` 7 | ReadTime string `json:"read_time,omitempty"` 8 | TenantKey string `json:"tenant_key,omitempty"` 9 | } `json:"reader,omitempty"` 10 | MessageIDList []string `json:"message_id_list,omitempty"` 11 | } 12 | 13 | // GetMessageRead . 14 | func (e EventV2) GetMessageRead() (*EventV2MessageRead, error) { 15 | var body EventV2MessageRead 16 | err := e.GetEvent(EventTypeMessageRead, &body) 17 | return &body, err 18 | } 19 | -------------------------------------------------------------------------------- /event_message_recalled.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // EventV2MessageRecalled . 4 | type EventV2MessageRecalled struct { 5 | MessageID string `json:"message_id,omitempty"` 6 | ChatID string `json:"chat_id,omitempty"` 7 | RecallTime string `json:"recall_time,omitempty"` 8 | RecallType string `json:"recall_type,omitempty"` 9 | } 10 | 11 | // GetMessageRecalled . 12 | func (e EventV2) GetMessageRecalled() (*EventV2MessageRecalled, error) { 13 | var body EventV2MessageRecalled 14 | err := e.GetEvent(EventTypeMessageRecalled, &body) 15 | return &body, err 16 | } 17 | -------------------------------------------------------------------------------- /event_message_received.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // EventV2MessageReceived . 4 | type EventV2MessageReceived struct { 5 | Sender struct { 6 | SenderID EventV2UserID `json:"sender_id,omitempty"` 7 | SenderType string `json:"sender_type,omitempty"` 8 | TenantKey string `json:"tenant_key,omitempty"` 9 | } `json:"sender,omitempty"` 10 | Message struct { 11 | MessageID string `json:"message_id,omitempty"` 12 | RootID string `json:"root_id,omitempty"` 13 | ParentID string `json:"parent_id,omitempty"` 14 | CreateTime string `json:"create_time,omitempty"` 15 | UpdateTime string `json:"update_time,omitempty"` 16 | ChatID string `json:"chat_id,omitempty"` 17 | ChatType string `json:"chat_type,omitempty"` 18 | ThreadID string `json:"thread_id,omitempty"` 19 | MessageType string `json:"message_type,omitempty"` 20 | Content string `json:"content,omitempty"` 21 | Mentions []struct { 22 | Key string `json:"key,omitempty"` 23 | ID EventV2UserID `json:"id,omitempty"` 24 | Name string `json:"name,omitempty"` 25 | TenantKey string `json:"tenant_key,omitempty"` 26 | } `json:"mentions,omitempty"` 27 | UserAgent string `json:"user_agent,omitempty"` 28 | } `json:"message,omitempty"` 29 | } 30 | 31 | // GetMessageReceived . 32 | func (e EventV2) GetMessageReceived() (*EventV2MessageReceived, error) { 33 | var body EventV2MessageReceived 34 | err := e.GetEvent(EventTypeMessageReceived, &body) 35 | return &body, err 36 | } 37 | -------------------------------------------------------------------------------- /event_user_added.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // EventV2UserAdded . 4 | type EventV2UserAdded struct { 5 | ChatID string `json:"chat_id,omitempty"` 6 | OperatorID EventV2UserID `json:"operator_id,omitempty"` 7 | External bool `json:"external,omitempty"` 8 | OperatorTenantKey string `json:"operator_tenant_key,omitempty"` 9 | Users []struct { 10 | Name string `json:"name,omitempty"` 11 | TenantKey string `json:"tenant_key,omitempty"` 12 | UserID EventV2UserID `json:"user_id,omitempty"` 13 | } `json:"users,omitempty"` 14 | } 15 | 16 | // GetUserAdded . 17 | func (e EventV2) GetUserAdded() (*EventV2UserAdded, error) { 18 | var body EventV2UserAdded 19 | err := e.GetEvent(EventTypeUserAdded, &body) 20 | return &body, err 21 | } 22 | -------------------------------------------------------------------------------- /event_user_deleted.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // EventV2UserDeleted . 4 | type EventV2UserDeleted = EventV2UserAdded 5 | 6 | // GetUserDeleted . 7 | func (e EventV2) GetUserDeleted() (*EventV2UserDeleted, error) { 8 | var body EventV2UserDeleted 9 | err := e.GetEvent(EventTypeUserDeleted, &body) 10 | return &body, err 11 | } 12 | -------------------------------------------------------------------------------- /event_v1.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "log" 7 | "net/http" 8 | ) 9 | 10 | // EventMessage . 11 | type EventMessage struct { 12 | UUID string `json:"uuid"` 13 | Timestamp string `json:"ts"` 14 | // Token is shown by Lark to indicate it is not a fake message, check at your own need 15 | Token string `json:"token"` 16 | EventType string `json:"type"` 17 | Event EventBody `json:"event"` 18 | } 19 | 20 | // EventBody . 21 | type EventBody struct { 22 | Type string `json:"type"` 23 | AppID string `json:"app_id"` 24 | TenantKey string `json:"tenant_key"` 25 | ChatType string `json:"chat_type"` 26 | MsgType string `json:"msg_type"` 27 | RootID string `json:"root_id,omitempty"` 28 | ParentID string `json:"parent_id,omitempty"` 29 | OpenID string `json:"open_id,omitempty"` 30 | OpenChatID string `json:"open_chat_id,omitempty"` 31 | OpenMessageID string `json:"open_message_id,omitempty"` 32 | IsMention bool `json:"is_mention,omitempty"` 33 | Title string `json:"title,omitempty"` 34 | Text string `json:"text,omitempty"` 35 | RealText string `json:"text_without_at_bot,omitempty"` 36 | ImageKey string `json:"image_key,omitempty"` 37 | ImageURL string `json:"image_url,omitempty"` 38 | FileKey string `json:"file_key,omitempty"` 39 | } 40 | 41 | // PostEvent posts event 42 | // 1. help to develop and test ServeEvent callback func much easier 43 | // 2. otherwise, you may use it to forward event 44 | func PostEvent(client *http.Client, hookURL string, message EventMessage) (*http.Response, error) { 45 | buf := new(bytes.Buffer) 46 | err := json.NewEncoder(buf).Encode(message) 47 | if err != nil { 48 | log.Printf("Encode json failed: %+v\n", err) 49 | return nil, err 50 | } 51 | resp, err := client.Post(hookURL, "application/json; charset=utf-8", buf) 52 | return resp, err 53 | } 54 | -------------------------------------------------------------------------------- /event_v1_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestPostEventMessage(t *testing.T) { 14 | message := EventMessage{ 15 | Timestamp: "", 16 | Token: "", 17 | EventType: "event_callback", 18 | Event: EventBody{ 19 | Type: "message", 20 | ChatType: "private", 21 | MsgType: "text", 22 | OpenID: testUserOpenID, 23 | Text: "private event", 24 | Title: "", 25 | OpenMessageID: "", 26 | ImageKey: "", 27 | ImageURL: "", 28 | }, 29 | } 30 | w := performRequest(func(w http.ResponseWriter, r *http.Request) { 31 | var m EventMessage 32 | json.NewDecoder(r.Body).Decode(&m) 33 | w.Write([]byte(m.Event.Text)) 34 | }, "POST", "/", message) 35 | assert.Equal(t, "private event", string(w.Body.Bytes())) 36 | 37 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 38 | m, _ := json.Marshal(message) 39 | w.Write([]byte(m)) 40 | })) 41 | defer ts.Close() 42 | resp, err := PostEvent(http.DefaultClient, ts.URL, message) 43 | if assert.NoError(t, err) { 44 | var event EventMessage 45 | body, err := ioutil.ReadAll(resp.Body) 46 | if assert.NoError(t, err) { 47 | defer resp.Body.Close() 48 | _ = json.Unmarshal(body, &event) 49 | assert.Equal(t, "event_callback", event.EventType) 50 | assert.Equal(t, "message", event.Event.Type) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /event_v2.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "log" 7 | "net/http" 8 | ) 9 | 10 | // EventType definitions 11 | const ( 12 | EventTypeMessageReceived = "im.message.receive_v1" 13 | EventTypeMessageRead = "im.message.message_read_v1" 14 | EventTypeMessageRecalled = "im.message.recalled_v1" 15 | EventTypeMessageReactionCreated = "im.message.reaction.created_v1" 16 | EventTypeMessageReactionDeleted = "im.message.reaction.deleted_v1" 17 | EventTypeChatDisbanded = "im.chat.disbanded_v1" 18 | EventTypeUserAdded = "im.chat.member.user.added_v1" 19 | EventTypeUserDeleted = "im.chat.member.user.deleted_v1" 20 | EventTypeBotAdded = "im.chat.member.bot.added_v1" 21 | EventTypeBotDeleted = "im.chat.member.bot.deleted_v1" 22 | // not supported yet 23 | EventTypeChatUpdated = "im.chat.updated_v1" 24 | EventTypeUserWithdrawn = "im.chat.member.user.withdrawn_v1" 25 | ) 26 | 27 | // EventV2 handles events with v2 schema 28 | type EventV2 struct { 29 | Schema string `json:"schema,omitempty"` 30 | Header EventV2Header `json:"header,omitempty"` 31 | 32 | EventRaw json.RawMessage `json:"event,omitempty"` 33 | Event interface{} `json:"-"` 34 | } 35 | 36 | // EventV2Header . 37 | type EventV2Header struct { 38 | EventID string `json:"event_id,omitempty"` 39 | EventType string `json:"event_type,omitempty"` 40 | CreateTime string `json:"create_time,omitempty"` 41 | Token string `json:"token,omitempty"` 42 | AppID string `json:"app_id,omitempty"` 43 | TenantKey string `json:"tenant_key,omitempty"` 44 | } 45 | 46 | // EventV2UserID . 47 | type EventV2UserID struct { 48 | UnionID string `json:"union_id,omitempty"` 49 | UserID string `json:"user_id,omitempty"` 50 | OpenID string `json:"open_id,omitempty"` 51 | } 52 | 53 | // PostEvent with event v2 format 54 | // and it's part of EventV2 instead of package method 55 | func (e EventV2) PostEvent(client *http.Client, hookURL string) (*http.Response, error) { 56 | buf := new(bytes.Buffer) 57 | err := json.NewEncoder(buf).Encode(e) 58 | if err != nil { 59 | log.Printf("Encode json failed: %+v\n", err) 60 | return nil, err 61 | } 62 | resp, err := client.Post(hookURL, "application/json; charset=utf-8", buf) 63 | return resp, err 64 | } 65 | 66 | // GetEvent . 67 | func (e EventV2) GetEvent(eventType string, body interface{}) error { 68 | if e.Header.EventType != eventType { 69 | return ErrEventTypeNotMatch 70 | } 71 | err := json.Unmarshal(e.EventRaw, &body) 72 | return err 73 | } 74 | -------------------------------------------------------------------------------- /event_v2_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestPostEventV2(t *testing.T) { 14 | message := EventV2{ 15 | Schema: "2.0", 16 | Header: EventV2Header{ 17 | AppID: "666", 18 | }, 19 | Event: EventBody{ 20 | Type: "message", 21 | ChatType: "group", 22 | MsgType: "text", 23 | OpenID: testUserOpenID, 24 | Text: "public event", 25 | Title: "", 26 | OpenMessageID: "", 27 | ImageKey: "", 28 | ImageURL: "", 29 | }, 30 | } 31 | w := performRequest(func(w http.ResponseWriter, r *http.Request) { 32 | var m EventV2 33 | json.NewDecoder(r.Body).Decode(&m) 34 | w.Write([]byte(m.Schema)) 35 | }, "POST", "/", message) 36 | assert.Equal(t, "2.0", string(w.Body.Bytes())) 37 | 38 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 39 | m, _ := json.Marshal(message) 40 | w.Write([]byte(m)) 41 | })) 42 | defer ts.Close() 43 | resp, err := message.PostEvent(http.DefaultClient, ts.URL) 44 | if assert.NoError(t, err) { 45 | var event EventV2 46 | body, err := ioutil.ReadAll(resp.Body) 47 | if assert.NoError(t, err) { 48 | defer resp.Body.Close() 49 | _ = json.Unmarshal(body, &event) 50 | assert.Equal(t, "2.0", event.Schema) 51 | assert.Equal(t, "666", event.Header.AppID) 52 | } 53 | } 54 | } 55 | 56 | func TestEventTypes(t *testing.T) { 57 | event := EventV2{ 58 | Header: EventV2Header{ 59 | EventType: EventTypeChatDisbanded, 60 | }, 61 | EventRaw: json.RawMessage(`{ "message": { "chat_id": "oc_ae7f3952a9b28588aeac46c9853d25d3", "chat_type": "p2p", "content": "{\"text\":\"333\"}", "create_time": "1641385820771", "message_id": "om_6ff2cff41a3e9248bbb19bf0e4762e6e", "message_type": "text" }, "sender": { "sender_id": { "open_id": "ou_4f75b532aff410181e93552ad0532072", "union_id": "on_2312aab89ab7c87beb9a443b2f3b1342", "user_id": "4gbb63af" }, "sender_type": "user", "tenant_key": "736588c9260f175d" } }`), 62 | } 63 | m, e := event.GetMessageReceived() 64 | assert.Error(t, e) 65 | event.Header.EventType = EventTypeMessageReceived 66 | m, e = event.GetMessageReceived() 67 | assert.NoError(t, e) 68 | assert.Equal(t, "p2p", m.Message.ChatType) 69 | } 70 | 71 | func TestGetEvent(t *testing.T) { 72 | event := EventV2{ 73 | Header: EventV2Header{ 74 | EventType: EventTypeMessageReceived, 75 | }, 76 | EventRaw: json.RawMessage(`{ "message": { "chat_id": "oc_ae7f3952a9b28588aeac46c9853d25d3", "chat_type": "p2p", "content": "{\"text\":\"333\"}", "create_time": "1641385820771", "message_id": "om_6ff2cff41a3e9248bbb19bf0e4762e6e", "message_type": "text" }, "sender": { "sender_id": { "open_id": "ou_4f75b532aff410181e93552ad0532072", "union_id": "on_2312aab89ab7c87beb9a443b2f3b1342", "user_id": "4gbb63af" }, "sender_type": "user", "tenant_key": "736588c9260f175d" } }`), 77 | } 78 | var ev EventV2MessageReceived 79 | err := event.GetEvent(EventTypeMessageReceived, &ev) 80 | if assert.NoError(t, err) { 81 | assert.Equal(t, "oc_ae7f3952a9b28588aeac46c9853d25d3", ev.Message.ChatID) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /fixtures/test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-lark/lark/392c494793c4d573ec1edbc934bb1b96d1227dbb/fixtures/test.jpg -------------------------------------------------------------------------------- /fixtures/test.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-lark/lark/392c494793c4d573ec1edbc934bb1b96d1227dbb/fixtures/test.pdf -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/go-lark/lark 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/davecgh/go-spew v1.1.1 // indirect 7 | github.com/go-lark/card-builder v1.0.0-beta.2 // indirect 8 | github.com/joho/godotenv v1.3.0 9 | github.com/stretchr/testify v1.7.0 10 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/go-lark/card-builder v1.0.0-beta.1 h1:yxUGUTVxn3nPpwVTXVNCYTSMeVGJMASw5s+8CMymJs4= 5 | github.com/go-lark/card-builder v1.0.0-beta.1/go.mod h1:7O2nuzjG4Cc/ty0s7Q7roYXOVxt9Gzrw0jBHsuP3UaM= 6 | github.com/go-lark/card-builder v1.0.0-beta.2 h1:DfH/14fs+R/G8CC+a2WoIp2wpWoD2isDcBpYZoSLwaU= 7 | github.com/go-lark/card-builder v1.0.0-beta.2/go.mod h1:7O2nuzjG4Cc/ty0s7Q7roYXOVxt9Gzrw0jBHsuP3UaM= 8 | github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= 9 | github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= 10 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 11 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 12 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 13 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 14 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 15 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 16 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 17 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 18 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= 19 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 20 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/http/httputil" 10 | ) 11 | 12 | // ExpandURL expands url path to full url 13 | func (bot Bot) ExpandURL(urlPath string) string { 14 | url := fmt.Sprintf("%s%s", bot.domain, urlPath) 15 | return url 16 | } 17 | 18 | func (bot Bot) httpErrorLog(prefix, text string, err error) { 19 | bot.logger.Log(bot.ctx, LogLevelError, fmt.Sprintf("[%s] %s: %+v\n", prefix, text, err)) 20 | } 21 | 22 | // DoAPIRequest builds http request 23 | func (bot Bot) DoAPIRequest( 24 | method string, 25 | prefix, urlPath string, 26 | header http.Header, auth bool, 27 | body io.Reader, 28 | output interface{}, 29 | ) error { 30 | var ( 31 | err error 32 | respBody io.ReadCloser 33 | url = bot.ExpandURL(urlPath) 34 | ) 35 | if header == nil { 36 | header = make(http.Header) 37 | } 38 | if auth { 39 | header.Add("Authorization", fmt.Sprintf("Bearer %s", bot.TenantAccessToken())) 40 | } 41 | if bot.useCustomClient { 42 | if bot.customClient == nil { 43 | return ErrCustomHTTPClientNotSet 44 | } 45 | respBody, err = bot.customClient.Do(bot.ctx, method, url, header, body) 46 | if err != nil { 47 | bot.httpErrorLog(prefix, "call failed", err) 48 | return err 49 | } 50 | } else { 51 | req, err := http.NewRequest(method, url, body) 52 | if err != nil { 53 | bot.httpErrorLog(prefix, "init request failed", err) 54 | return err 55 | } 56 | req.Header = header 57 | resp, err := bot.client.Do(req) 58 | if err != nil { 59 | bot.httpErrorLog(prefix, "call failed", err) 60 | return err 61 | } 62 | if bot.debug { 63 | b, _ := httputil.DumpResponse(resp, true) 64 | bot.logger.Log(bot.ctx, LogLevelDebug, string(b)) 65 | } 66 | respBody = resp.Body 67 | } 68 | defer respBody.Close() 69 | err = json.NewDecoder(respBody).Decode(&output) 70 | if err != nil { 71 | bot.httpErrorLog(prefix, "decode body failed", err) 72 | return err 73 | } 74 | return err 75 | } 76 | 77 | func (bot Bot) wrapAPIRequest(method, prefix, urlPath string, auth bool, params interface{}, output interface{}) error { 78 | buf := new(bytes.Buffer) 79 | err := json.NewEncoder(buf).Encode(params) 80 | if err != nil { 81 | bot.httpErrorLog(prefix, "encode JSON failed", err) 82 | return err 83 | } 84 | 85 | header := make(http.Header) 86 | header.Set("Content-Type", "application/json; charset=utf-8") 87 | err = bot.DoAPIRequest(method, prefix, urlPath, header, auth, buf, output) 88 | if err != nil { 89 | return err 90 | } 91 | return nil 92 | } 93 | 94 | // PostAPIRequest call Lark API 95 | func (bot Bot) PostAPIRequest(prefix, urlPath string, auth bool, params interface{}, output interface{}) error { 96 | return bot.wrapAPIRequest(http.MethodPost, prefix, urlPath, auth, params, output) 97 | } 98 | 99 | // GetAPIRequest call Lark API 100 | func (bot Bot) GetAPIRequest(prefix, urlPath string, auth bool, params interface{}, output interface{}) error { 101 | return bot.wrapAPIRequest(http.MethodGet, prefix, urlPath, auth, params, output) 102 | } 103 | 104 | // DeleteAPIRequest call Lark API 105 | func (bot Bot) DeleteAPIRequest(prefix, urlPath string, auth bool, params interface{}, output interface{}) error { 106 | return bot.wrapAPIRequest(http.MethodDelete, prefix, urlPath, auth, params, output) 107 | } 108 | 109 | // PutAPIRequest call Lark API 110 | func (bot Bot) PutAPIRequest(prefix, urlPath string, auth bool, params interface{}, output interface{}) error { 111 | return bot.wrapAPIRequest(http.MethodPut, prefix, urlPath, auth, params, output) 112 | } 113 | 114 | // PatchAPIRequest call Lark API 115 | func (bot Bot) PatchAPIRequest(prefix, urlPath string, auth bool, params interface{}, output interface{}) error { 116 | return bot.wrapAPIRequest(http.MethodPatch, prefix, urlPath, auth, params, output) 117 | } 118 | -------------------------------------------------------------------------------- /http_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestExpandURL(t *testing.T) { 10 | bot := NewChatBot("test-id", "test-secret") 11 | bot.SetDomain("http://localhost") 12 | assert.Equal(t, bot.ExpandURL("/test"), 13 | "http://localhost/test") 14 | } 15 | -------------------------------------------------------------------------------- /http_wrapper.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | ) 8 | 9 | // HTTPWrapper is a wrapper interface, which enables extension on HTTP part. 10 | // Typicall, we do not need this because default client is sufficient. 11 | type HTTPWrapper interface { 12 | Do( 13 | ctx context.Context, 14 | method, url string, 15 | header http.Header, 16 | body io.Reader) (io.ReadCloser, error) 17 | } 18 | -------------------------------------------------------------------------------- /lark.go: -------------------------------------------------------------------------------- 1 | // Package lark is an easy-to-use SDK for Feishu and Lark Open Platform, 2 | // which implements messaging APIs, with full-fledged supports on building Chat Bot and Notification Bot. 3 | package lark 4 | 5 | import ( 6 | "context" 7 | "net/http" 8 | "sync/atomic" 9 | "time" 10 | ) 11 | 12 | const ( 13 | // ChatBot should be created with NewChatBot 14 | // Create from https://open.feishu.cn/ or https://open.larksuite.com/ 15 | ChatBot = iota 16 | // NotificationBot for webhook, behave as a simpler notification bot 17 | // Create from Lark group 18 | NotificationBot 19 | ) 20 | 21 | // Bot definition 22 | type Bot struct { 23 | // bot type 24 | botType int 25 | // Auth info 26 | appID string 27 | appSecret string 28 | accessToken atomic.Value 29 | tenantAccessToken atomic.Value 30 | 31 | // user id type for api chat 32 | userIDType string 33 | // webhook for NotificationBot 34 | webhook string 35 | // API Domain 36 | domain string 37 | // http client 38 | client *http.Client 39 | // custom http client 40 | useCustomClient bool 41 | customClient HTTPWrapper 42 | // auth heartbeat 43 | heartbeat chan bool 44 | // auth heartbeat counter (for testing) 45 | heartbeatCounter int64 46 | 47 | ctx context.Context 48 | logger LogWrapper 49 | debug bool 50 | } 51 | 52 | // Domains 53 | const ( 54 | DomainFeishu = "https://open.feishu.cn" 55 | DomainLark = "https://open.larksuite.com" 56 | ) 57 | 58 | // NewChatBot with appID and appSecret 59 | func NewChatBot(appID, appSecret string) *Bot { 60 | bot := &Bot{ 61 | botType: ChatBot, 62 | appID: appID, 63 | appSecret: appSecret, 64 | client: initClient(), 65 | domain: DomainFeishu, 66 | ctx: context.Background(), 67 | logger: initDefaultLogger(), 68 | } 69 | bot.accessToken.Store("") 70 | bot.tenantAccessToken.Store("") 71 | 72 | return bot 73 | } 74 | 75 | // NewNotificationBot with URL 76 | func NewNotificationBot(hookURL string) *Bot { 77 | bot := &Bot{ 78 | botType: NotificationBot, 79 | webhook: hookURL, 80 | client: initClient(), 81 | ctx: context.Background(), 82 | logger: initDefaultLogger(), 83 | } 84 | bot.accessToken.Store("") 85 | bot.tenantAccessToken.Store("") 86 | 87 | return bot 88 | } 89 | 90 | // requireType checks whether the action is allowed in a list of bot types 91 | func (bot Bot) requireType(botType ...int) bool { 92 | for _, iterType := range botType { 93 | if bot.botType == iterType { 94 | return true 95 | } 96 | } 97 | return false 98 | } 99 | 100 | // SetClient assigns a new client to bot.client 101 | func (bot *Bot) SetClient(c *http.Client) { 102 | bot.client = c 103 | } 104 | 105 | func initClient() *http.Client { 106 | return &http.Client{ 107 | Timeout: 5 * time.Second, 108 | } 109 | } 110 | 111 | // SetCustomClient . 112 | func (bot *Bot) SetCustomClient(c HTTPWrapper) { 113 | bot.useCustomClient = true 114 | bot.customClient = c 115 | } 116 | 117 | // UnsetCustomClient . 118 | func (bot *Bot) UnsetCustomClient() { 119 | bot.useCustomClient = false 120 | bot.customClient = nil 121 | } 122 | 123 | // SetDomain set domain of endpoint, so we could call Feishu/Lark 124 | // go-lark does not check your host, just use the right one or fail. 125 | func (bot *Bot) SetDomain(domain string) { 126 | bot.domain = domain 127 | } 128 | 129 | // Domain returns current domain 130 | func (bot Bot) Domain() string { 131 | return bot.domain 132 | } 133 | 134 | // AppID returns bot.appID for external use 135 | func (bot Bot) AppID() string { 136 | return bot.appID 137 | } 138 | 139 | // BotType returns bot.botType for external use 140 | func (bot Bot) BotType() int { 141 | return bot.botType 142 | } 143 | 144 | // AccessToken returns bot.accessToken for external use 145 | func (bot Bot) AccessToken() string { 146 | return bot.accessToken.Load().(string) 147 | } 148 | 149 | // TenantAccessToken returns bot.tenantAccessToken for external use 150 | func (bot Bot) TenantAccessToken() string { 151 | return bot.tenantAccessToken.Load().(string) 152 | } 153 | 154 | // SetWebhook updates webhook URL 155 | func (bot *Bot) SetWebhook(url string) { 156 | bot.webhook = url 157 | } 158 | -------------------------------------------------------------------------------- /lark_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "io" 8 | "log" 9 | "net/http" 10 | "net/http/httptest" 11 | "os" 12 | "testing" 13 | 14 | "github.com/joho/godotenv" 15 | "github.com/stretchr/testify/assert" 16 | ) 17 | 18 | // IDs for test use 19 | var ( 20 | testAppID string 21 | testAppSecret string 22 | testUserEmail string 23 | testUserOpenID string 24 | testUserID string 25 | testUserUnionID string 26 | testGroupChatID string 27 | testWebhookV1 string 28 | testWebhookV2 string 29 | testWebhookV2Signed string 30 | ) 31 | 32 | func newTestBot() *Bot { 33 | testMode := os.Getenv("GO_LARK_TEST_MODE") 34 | if testMode == "" { 35 | testMode = "testing" 36 | } 37 | if testMode == "local" { 38 | err := godotenv.Load(".env") 39 | if err != nil { 40 | panic(err) 41 | } 42 | } 43 | testAppID = os.Getenv("LARK_APP_ID") 44 | testAppSecret = os.Getenv("LARK_APP_SECRET") 45 | testUserEmail = os.Getenv("LARK_USER_EMAIL") 46 | testUserID = os.Getenv("LARK_USER_ID") 47 | testUserUnionID = os.Getenv("LARK_UNION_ID") 48 | testUserOpenID = os.Getenv("LARK_OPEN_ID") 49 | testGroupChatID = os.Getenv("LARK_CHAT_ID") 50 | testWebhookV1 = os.Getenv("LARK_WEBHOOK_V1") 51 | testWebhookV2 = os.Getenv("LARK_WEBHOOK_V2") 52 | testWebhookV2Signed = os.Getenv("LARK_WEBHOOK_V2_SIGNED") 53 | if len(testAppID) == 0 || 54 | len(testAppSecret) == 0 || 55 | len(testUserEmail) == 0 || 56 | len(testUserID) == 0 || 57 | len(testUserUnionID) == 0 || 58 | len(testUserOpenID) == 0 || 59 | len(testGroupChatID) == 0 { 60 | panic("insufficient test environment") 61 | } 62 | return NewChatBot(testAppID, testAppSecret) 63 | } 64 | 65 | func captureOutput(f func()) string { 66 | var buf bytes.Buffer 67 | log.SetOutput(&buf) 68 | f() 69 | log.SetOutput(os.Stderr) 70 | return buf.String() 71 | } 72 | 73 | func performRequest(r http.HandlerFunc, method, path string, body interface{}) *httptest.ResponseRecorder { 74 | buf := new(bytes.Buffer) 75 | json.NewEncoder(buf).Encode(body) 76 | req := httptest.NewRequest(method, path, buf) 77 | w := httptest.NewRecorder() 78 | r.ServeHTTP(w, req) 79 | return w 80 | } 81 | 82 | // for general API test suites 83 | var bot *Bot 84 | 85 | func init() { 86 | bot = newTestBot() 87 | _, _ = bot.GetTenantAccessTokenInternal(true) 88 | } 89 | 90 | func TestBotProperties(t *testing.T) { 91 | chatBot := newTestBot() 92 | assert.NotEmpty(t, chatBot.appID) 93 | assert.NotEmpty(t, chatBot.appSecret) 94 | assert.Empty(t, chatBot.webhook) 95 | assert.Equal(t, DomainFeishu, chatBot.domain) 96 | assert.Equal(t, ChatBot, chatBot.botType) 97 | assert.NotNil(t, chatBot.client) 98 | assert.NotNil(t, chatBot.logger) 99 | 100 | notifyBot := NewNotificationBot(testWebhookV1) 101 | assert.Empty(t, notifyBot.appID) 102 | assert.Empty(t, notifyBot.appSecret) 103 | assert.NotEmpty(t, notifyBot.webhook) 104 | assert.Empty(t, notifyBot.domain) 105 | assert.Equal(t, NotificationBot, notifyBot.botType) 106 | assert.NotNil(t, notifyBot.client) 107 | assert.NotNil(t, notifyBot.logger) 108 | } 109 | 110 | func TestRequiredType(t *testing.T) { 111 | bot := newTestBot() 112 | assert.True(t, bot.requireType(ChatBot)) 113 | assert.False(t, bot.requireType(NotificationBot)) 114 | } 115 | 116 | func TestSetDomain(t *testing.T) { 117 | bot := newTestBot() 118 | assert.Equal(t, DomainFeishu, bot.domain) 119 | assert.Equal(t, DomainFeishu, bot.Domain()) 120 | bot.SetDomain("https://test.test") 121 | assert.Equal(t, "https://test.test", bot.domain) 122 | assert.Equal(t, "https://test.test", bot.Domain()) 123 | } 124 | 125 | func TestBotGetters(t *testing.T) { 126 | bot := newTestBot() 127 | assert.Equal(t, testAppID, bot.AppID()) 128 | assert.Equal(t, ChatBot, bot.BotType()) 129 | assert.Equal(t, "", bot.AccessToken()) 130 | assert.Equal(t, "", bot.TenantAccessToken()) 131 | } 132 | 133 | func TestSetClient(t *testing.T) { 134 | bot := &Bot{} 135 | assert.Nil(t, bot.client) 136 | bot.SetClient(&http.Client{}) 137 | assert.NotNil(t, bot.client) 138 | } 139 | 140 | type customHTTPWrapper struct { 141 | client *http.Client 142 | } 143 | 144 | func (c customHTTPWrapper) Do(ctx context.Context, method, url string, header http.Header, body io.Reader) (io.ReadCloser, error) { 145 | return nil, nil 146 | } 147 | 148 | func TestCustomClient(t *testing.T) { 149 | bot := &Bot{} 150 | assert.Nil(t, bot.customClient) 151 | var c customHTTPWrapper 152 | bot.SetCustomClient(c) 153 | assert.NotNil(t, bot.customClient) 154 | } 155 | 156 | func TestUpdateWebhook(t *testing.T) { 157 | bot := NewNotificationBot("abc") 158 | assert.Equal(t, "abc", bot.webhook) 159 | bot.SetWebhook("def") 160 | assert.Equal(t, "def", bot.webhook) 161 | } 162 | -------------------------------------------------------------------------------- /locale.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // Supported Lark locales 4 | const ( 5 | LocaleZhCN = "zh_cn" 6 | LocaleZhHK = "zh_hk" 7 | LocaleZhTW = "zh_tw" 8 | LocaleEnUS = "en_us" 9 | LocaleJaJP = "ja_jp" 10 | ) 11 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "log" 7 | "os" 8 | ) 9 | 10 | // LogLevel defs 11 | type LogLevel int 12 | 13 | // LogLevels 14 | const ( 15 | LogLevelTrace = iota + 1 16 | LogLevelDebug 17 | LogLevelInfo 18 | LogLevelWarn 19 | LogLevelError 20 | ) 21 | 22 | // LogWrapper interface 23 | type LogWrapper interface { 24 | // for log print 25 | Log(context.Context, LogLevel, string) 26 | // for test redirection 27 | SetOutput(io.Writer) 28 | } 29 | 30 | // String . 31 | func (ll LogLevel) String() string { 32 | switch ll { 33 | case LogLevelTrace: 34 | return "TRACE" 35 | case LogLevelDebug: 36 | return "DEBUG" 37 | case LogLevelInfo: 38 | return "INFO" 39 | case LogLevelWarn: 40 | return "WARN" 41 | case LogLevelError: 42 | return "ERROR" 43 | } 44 | return "" 45 | } 46 | 47 | type stdLogger struct { 48 | *log.Logger 49 | } 50 | 51 | func (sl stdLogger) Log(_ context.Context, level LogLevel, msg string) { 52 | sl.Printf("[%s] %s\n", level, msg) 53 | } 54 | 55 | const logPrefix = "[go-lark] " 56 | 57 | func initDefaultLogger() LogWrapper { 58 | // create a default std logger 59 | logger := stdLogger{ 60 | log.New(os.Stderr, logPrefix, log.LstdFlags), 61 | } 62 | return logger 63 | } 64 | 65 | // SetLogger set a new logger 66 | func (bot *Bot) SetLogger(logger LogWrapper) { 67 | bot.logger = logger 68 | } 69 | 70 | // Logger returns current logger 71 | func (bot Bot) Logger() LogWrapper { 72 | return bot.logger 73 | } 74 | 75 | // WithContext . 76 | func (bot *Bot) WithContext(ctx context.Context) *Bot { 77 | bot.ctx = ctx 78 | return bot 79 | } 80 | -------------------------------------------------------------------------------- /logger_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestSetLogger(t *testing.T) { 12 | bot := newTestBot() 13 | newLogger := initDefaultLogger() 14 | bot.SetLogger(newLogger) 15 | assert.Equal(t, newLogger, bot.logger) 16 | assert.Equal(t, newLogger, bot.Logger()) 17 | } 18 | 19 | func TestLogLevel(t *testing.T) { 20 | var logLevel LogLevel = LogLevelDebug 21 | assert.Equal(t, "DEBUG", logLevel.String()) 22 | logLevel = LogLevelError 23 | assert.Equal(t, "ERROR", logLevel.String()) 24 | logLevel = LogLevelTrace 25 | assert.Equal(t, "TRACE", logLevel.String()) 26 | logLevel = LogLevelWarn 27 | assert.Equal(t, "WARN", logLevel.String()) 28 | logLevel = LogLevelInfo 29 | assert.Equal(t, "INFO", logLevel.String()) 30 | logLevel = 1000 31 | assert.Equal(t, "", logLevel.String()) 32 | } 33 | 34 | func TestWithContext(t *testing.T) { 35 | bot := newTestBot() 36 | assert.Equal(t, "context.Background", fmt.Sprintf("%s", bot.ctx)) 37 | bot.WithContext(context.TODO()) 38 | assert.Equal(t, "context.TODO", fmt.Sprintf("%s", bot.ctx)) 39 | } 40 | -------------------------------------------------------------------------------- /message.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // Msg Types 4 | const ( 5 | MsgText = "text" 6 | MsgPost = "post" 7 | MsgInteractive = "interactive" 8 | MsgImage = "image" 9 | MsgShareCard = "share_chat" 10 | MsgShareUser = "share_user" 11 | MsgAudio = "audio" 12 | MsgMedia = "media" 13 | MsgFile = "file" 14 | MsgSticker = "sticker" 15 | ) 16 | 17 | // OutcomingMessage struct of an outcoming message 18 | type OutcomingMessage struct { 19 | MsgType string `json:"msg_type"` 20 | Content MessageContent `json:"content"` 21 | Card CardContent `json:"card"` 22 | // ID for user 23 | UIDType string `json:"-"` 24 | OpenID string `json:"open_id,omitempty"` 25 | Email string `json:"email,omitempty"` 26 | UserID string `json:"user_id,omitempty"` 27 | ChatID string `json:"chat_id,omitempty"` 28 | UnionID string `json:"-"` 29 | // For reply 30 | RootID string `json:"root_id,omitempty"` 31 | ReplyInThread bool `json:"reply_in_thread,omitempty"` 32 | // Sign for notification bot 33 | Sign string `json:"sign"` 34 | // Timestamp for sign 35 | Timestamp int64 `json:"timestamp"` 36 | // UUID for idempotency 37 | UUID string `json:"uuid"` 38 | } 39 | 40 | // CardContent struct of card content 41 | type CardContent map[string]interface{} 42 | 43 | // MessageContent struct of message content 44 | type MessageContent struct { 45 | Text *TextContent `json:"text,omitempty"` 46 | Image *ImageContent `json:"image,omitempty"` 47 | Post *PostContent `json:"post,omitempty"` 48 | Card *CardContent `json:"card,omitempty"` 49 | ShareChat *ShareChatContent `json:"share_chat,omitempty"` 50 | ShareUser *ShareUserContent `json:"share_user,omitempty"` 51 | Audio *AudioContent `json:"audio,omitempty"` 52 | Media *MediaContent `json:"media,omitempty"` 53 | File *FileContent `json:"file,omitempty"` 54 | Sticker *StickerContent `json:"sticker,omitempty"` 55 | Template *TemplateContent `json:"template,omitempty"` 56 | } 57 | 58 | // TextContent . 59 | type TextContent struct { 60 | Text string `json:"text"` 61 | } 62 | 63 | // ImageContent . 64 | type ImageContent struct { 65 | ImageKey string `json:"image_key"` 66 | } 67 | 68 | // ShareChatContent . 69 | type ShareChatContent struct { 70 | ChatID string `json:"chat_id"` 71 | } 72 | 73 | // ShareUserContent . 74 | type ShareUserContent struct { 75 | UserID string `json:"user_id"` 76 | } 77 | 78 | // AudioContent . 79 | type AudioContent struct { 80 | FileKey string `json:"file_key"` 81 | } 82 | 83 | // MediaContent . 84 | type MediaContent struct { 85 | FileName string `json:"file_name,omitempty"` 86 | FileKey string `json:"file_key"` 87 | ImageKey string `json:"image_key"` 88 | Duration int `json:"duration,omitempty"` 89 | } 90 | 91 | // FileContent . 92 | type FileContent struct { 93 | FileName string `json:"file_name,omitempty"` 94 | FileKey string `json:"file_key"` 95 | } 96 | 97 | // StickerContent . 98 | type StickerContent struct { 99 | FileKey string `json:"file_key"` 100 | } 101 | 102 | // TemplateContent . 103 | type TemplateContent struct { 104 | Type string `json:"type"` 105 | Data templateData `json:"data,omitempty"` 106 | } 107 | 108 | type templateData struct { 109 | TemplateID string `json:"template_id"` 110 | TemplateVersionName string `json:"template_version_name,omitempty"` 111 | TemplateVariable map[string]interface{} `json:"template_variable,omitempty"` 112 | } 113 | -------------------------------------------------------------------------------- /message_v1.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import "strconv" 4 | 5 | // BuildOutcomingMessageReq for msg builder 6 | func BuildOutcomingMessageReq(om OutcomingMessage) map[string]interface{} { 7 | params := map[string]interface{}{ 8 | "msg_type": om.MsgType, 9 | "chat_id": om.ChatID, // request must contain chat_id, even if it is empty 10 | } 11 | params[om.UIDType] = buildReceiveID(om) 12 | if len(om.RootID) > 0 { 13 | params["root_id"] = om.RootID 14 | } 15 | content := make(map[string]interface{}) 16 | if om.Content.Text != nil { 17 | content["text"] = om.Content.Text.Text 18 | } 19 | if om.Content.Image != nil { 20 | content["image_key"] = om.Content.Image.ImageKey 21 | } 22 | if om.Content.ShareChat != nil { 23 | content["share_open_chat_id"] = om.Content.ShareChat.ChatID 24 | } 25 | if om.Content.Post != nil { 26 | content["post"] = *om.Content.Post 27 | } 28 | if om.MsgType == MsgInteractive && om.Content.Card != nil { 29 | params["card"] = *om.Content.Card 30 | } 31 | if len(om.Sign) > 0 { 32 | params["sign"] = om.Sign 33 | params["timestamp"] = strconv.FormatInt(om.Timestamp, 10) 34 | } 35 | params["content"] = content 36 | return params 37 | } 38 | 39 | func buildOutcomingNotification(om OutcomingMessage) map[string]interface{} { 40 | params := map[string]interface{}{ 41 | "msg_type": om.MsgType, 42 | } 43 | if len(om.RootID) > 0 { 44 | params["root_id"] = om.RootID 45 | } 46 | content := make(map[string]interface{}) 47 | if om.Content.Text != nil { 48 | content["text"] = om.Content.Text.Text 49 | } 50 | if om.Content.Image != nil { 51 | content["image_key"] = om.Content.Image.ImageKey 52 | } 53 | if om.Content.ShareChat != nil { 54 | content["share_open_chat_id"] = om.Content.ShareChat.ChatID 55 | } 56 | if om.Content.Post != nil { 57 | content["post"] = *om.Content.Post 58 | } 59 | if om.MsgType == MsgInteractive && om.Content.Card != nil { 60 | params["card"] = *om.Content.Card 61 | } 62 | if len(om.Sign) > 0 { 63 | params["sign"] = om.Sign 64 | params["timestamp"] = strconv.FormatInt(om.Timestamp, 10) 65 | } 66 | params["content"] = content 67 | return params 68 | } 69 | -------------------------------------------------------------------------------- /message_v2.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | // BuildMessage . 8 | func BuildMessage(om OutcomingMessage) (*IMMessageRequest, error) { 9 | req := IMMessageRequest{ 10 | MsgType: string(om.MsgType), 11 | Content: buildContent(om), 12 | ReceiveID: buildReceiveID(om), 13 | } 14 | if req.ReceiveID == "" { 15 | return nil, ErrInvalidReceiveID 16 | } 17 | if req.Content == "" { 18 | return nil, ErrMessageNotBuild 19 | } 20 | if om.UUID != "" { 21 | req.UUID = om.UUID 22 | } 23 | return &req, nil 24 | } 25 | 26 | func buildReplyMessage(om OutcomingMessage) (*IMMessageRequest, error) { 27 | req := IMMessageRequest{ 28 | MsgType: string(om.MsgType), 29 | Content: buildContent(om), 30 | ReceiveID: buildReceiveID(om), 31 | } 32 | if req.Content == "" { 33 | return nil, ErrMessageNotBuild 34 | } 35 | if om.ReplyInThread { 36 | req.ReplyInThread = om.ReplyInThread 37 | } 38 | if om.UUID != "" { 39 | req.UUID = om.UUID 40 | } 41 | 42 | return &req, nil 43 | } 44 | 45 | func buildUpdateMessage(om OutcomingMessage) (*IMMessageRequest, error) { 46 | req := IMMessageRequest{ 47 | Content: buildContent(om), 48 | } 49 | if om.MsgType != MsgInteractive { 50 | req.MsgType = om.MsgType 51 | } 52 | if req.Content == "" { 53 | return nil, ErrMessageNotBuild 54 | } 55 | 56 | return &req, nil 57 | } 58 | 59 | func buildContent(om OutcomingMessage) string { 60 | var ( 61 | content = "" 62 | b []byte 63 | err error 64 | ) 65 | switch om.MsgType { 66 | case MsgText: 67 | b, err = json.Marshal(om.Content.Text) 68 | case MsgImage: 69 | b, err = json.Marshal(om.Content.Image) 70 | case MsgFile: 71 | b, err = json.Marshal(om.Content.File) 72 | case MsgShareCard: 73 | b, err = json.Marshal(om.Content.ShareChat) 74 | case MsgShareUser: 75 | b, err = json.Marshal(om.Content.ShareUser) 76 | case MsgPost: 77 | b, err = json.Marshal(om.Content.Post) 78 | case MsgInteractive: 79 | if om.Content.Card != nil { 80 | b, err = json.Marshal(om.Content.Card) 81 | } else if om.Content.Template != nil { 82 | b, err = json.Marshal(om.Content.Template) 83 | } 84 | case MsgAudio: 85 | b, err = json.Marshal(om.Content.Audio) 86 | case MsgMedia: 87 | b, err = json.Marshal(om.Content.Media) 88 | case MsgSticker: 89 | b, err = json.Marshal(om.Content.Sticker) 90 | } 91 | if err != nil { 92 | return "" 93 | } 94 | content = string(b) 95 | 96 | return content 97 | } 98 | 99 | func buildReceiveID(om OutcomingMessage) string { 100 | switch om.UIDType { 101 | case UIDEmail: 102 | return om.Email 103 | case UIDUserID: 104 | return om.UserID 105 | case UIDOpenID: 106 | return om.OpenID 107 | case UIDChatID: 108 | return om.ChatID 109 | case UIDUnionID: 110 | return om.UnionID 111 | } 112 | return "" 113 | } 114 | -------------------------------------------------------------------------------- /msg_buf.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // MsgBuffer stores all the messages attached 9 | // You can call every function, but some of which is only available for specific condition 10 | type MsgBuffer struct { 11 | // Message type 12 | msgType string 13 | // Output 14 | message OutcomingMessage 15 | 16 | err error 17 | } 18 | 19 | // NewMsgBuffer create a message buffer 20 | func NewMsgBuffer(newMsgType string) *MsgBuffer { 21 | msgBuffer := MsgBuffer{ 22 | message: OutcomingMessage{ 23 | MsgType: newMsgType, 24 | }, 25 | msgType: newMsgType, 26 | } 27 | return &msgBuffer 28 | } 29 | 30 | // BindOpenID binds open_id 31 | func (m *MsgBuffer) BindOpenID(openID string) *MsgBuffer { 32 | m.message.OpenID = openID 33 | m.message.UIDType = UIDOpenID 34 | return m 35 | } 36 | 37 | // BindEmail binds email 38 | func (m *MsgBuffer) BindEmail(email string) *MsgBuffer { 39 | m.message.Email = email 40 | m.message.UIDType = UIDEmail 41 | return m 42 | } 43 | 44 | // BindChatID binds chat_id 45 | func (m *MsgBuffer) BindChatID(chatID string) *MsgBuffer { 46 | m.message.ChatID = chatID 47 | m.message.UIDType = UIDChatID 48 | return m 49 | } 50 | 51 | // BindOpenChatID binds open_chat_id 52 | func (m *MsgBuffer) BindOpenChatID(openChatID string) *MsgBuffer { 53 | m.BindChatID(openChatID) 54 | m.message.UIDType = UIDChatID 55 | return m 56 | } 57 | 58 | // BindUserID binds open_id 59 | func (m *MsgBuffer) BindUserID(userID string) *MsgBuffer { 60 | m.message.UserID = userID 61 | m.message.UIDType = UIDUserID 62 | return m 63 | } 64 | 65 | // BindUnionID binds union_id 66 | func (m *MsgBuffer) BindUnionID(unionID string) *MsgBuffer { 67 | m.message.UnionID = unionID 68 | m.message.UIDType = UIDUnionID 69 | return m 70 | } 71 | 72 | // BindReply binds root id for reply 73 | // rootID is OpenMessageID of the message you reply 74 | func (m *MsgBuffer) BindReply(rootID string) *MsgBuffer { 75 | m.message.RootID = rootID 76 | return m 77 | } 78 | 79 | // ReplyInThread replies message in thread 80 | func (m *MsgBuffer) ReplyInThread(replyInThread bool) *MsgBuffer { 81 | m.message.ReplyInThread = replyInThread 82 | return m 83 | } 84 | 85 | // WithSign generates sign for notification bot check 86 | func (m *MsgBuffer) WithSign(secret string, ts int64) *MsgBuffer { 87 | m.message.Sign, _ = GenSign(secret, ts) 88 | m.message.Timestamp = ts 89 | return m 90 | } 91 | 92 | // WithUUID add UUID to message for idempotency 93 | func (m *MsgBuffer) WithUUID(uuid string) *MsgBuffer { 94 | m.message.UUID = uuid 95 | return m 96 | } 97 | 98 | func (m MsgBuffer) typeError(funcName string, msgType string) error { 99 | return fmt.Errorf("`%s` is only available to `%s`", funcName, msgType) 100 | } 101 | 102 | // Text attaches text 103 | func (m *MsgBuffer) Text(text string) *MsgBuffer { 104 | if m.msgType != MsgText { 105 | m.err = m.typeError("Text", MsgText) 106 | return m 107 | } 108 | m.message.Content.Text = &TextContent{ 109 | Text: text, 110 | } 111 | return m 112 | } 113 | 114 | // Image attaches image key 115 | // for MsgImage only 116 | func (m *MsgBuffer) Image(imageKey string) *MsgBuffer { 117 | if m.msgType != MsgImage { 118 | m.err = m.typeError("Image", MsgImage) 119 | return m 120 | } 121 | m.message.Content.Image = &ImageContent{ 122 | ImageKey: imageKey, 123 | } 124 | return m 125 | } 126 | 127 | // ShareChat attaches chat id 128 | // for MsgShareChat only 129 | func (m *MsgBuffer) ShareChat(chatID string) *MsgBuffer { 130 | if m.msgType != MsgShareCard { 131 | m.err = m.typeError("ShareChat", MsgShareCard) 132 | return m 133 | } 134 | m.message.Content.ShareChat = &ShareChatContent{ 135 | ChatID: chatID, 136 | } 137 | return m 138 | } 139 | 140 | // ShareUser attaches user id 141 | // for MsgShareUser only 142 | func (m *MsgBuffer) ShareUser(userID string) *MsgBuffer { 143 | if m.msgType != MsgShareUser { 144 | m.err = m.typeError("ShareUser", MsgShareUser) 145 | return m 146 | } 147 | m.message.Content.ShareUser = &ShareUserContent{ 148 | UserID: userID, 149 | } 150 | return m 151 | } 152 | 153 | // File attaches file 154 | // for MsgFile only 155 | func (m *MsgBuffer) File(fileKey string) *MsgBuffer { 156 | if m.msgType != MsgFile { 157 | m.err = m.typeError("File", MsgFile) 158 | return m 159 | } 160 | m.message.Content.File = &FileContent{ 161 | FileKey: fileKey, 162 | } 163 | return m 164 | } 165 | 166 | // Audio attaches audio 167 | // for MsgAudio only 168 | func (m *MsgBuffer) Audio(fileKey string) *MsgBuffer { 169 | if m.msgType != MsgAudio { 170 | m.err = m.typeError("Audio", MsgAudio) 171 | return m 172 | } 173 | m.message.Content.Audio = &AudioContent{ 174 | FileKey: fileKey, 175 | } 176 | return m 177 | } 178 | 179 | // Media attaches media 180 | // for MsgMedia only 181 | func (m *MsgBuffer) Media(fileKey, imageKey string) *MsgBuffer { 182 | if m.msgType != MsgMedia { 183 | m.err = m.typeError("Media", MsgMedia) 184 | return m 185 | } 186 | m.message.Content.Media = &MediaContent{ 187 | FileKey: fileKey, 188 | ImageKey: imageKey, 189 | } 190 | return m 191 | } 192 | 193 | // Sticker attaches sticker 194 | // for MsgSticker only 195 | func (m *MsgBuffer) Sticker(fileKey string) *MsgBuffer { 196 | if m.msgType != MsgSticker { 197 | m.err = m.typeError("Sticker", MsgSticker) 198 | return m 199 | } 200 | m.message.Content.Sticker = &StickerContent{ 201 | FileKey: fileKey, 202 | } 203 | return m 204 | } 205 | 206 | // Post sets raw post content 207 | func (m *MsgBuffer) Post(postContent *PostContent) *MsgBuffer { 208 | if m.msgType != MsgPost { 209 | m.err = m.typeError("Post", MsgPost) 210 | return m 211 | } 212 | m.message.Content.Post = postContent 213 | return m 214 | } 215 | 216 | // Card binds card content with V4 format 217 | func (m *MsgBuffer) Card(cardContent string) *MsgBuffer { 218 | if m.msgType != MsgInteractive { 219 | m.err = m.typeError("Card", MsgInteractive) 220 | return m 221 | } 222 | card := make(CardContent) 223 | _ = json.Unmarshal([]byte(cardContent), &card) 224 | m.message.Content.Card = &card 225 | return m 226 | } 227 | 228 | // Template sets raw template content 229 | func (m *MsgBuffer) Template(tempateContent *TemplateContent) *MsgBuffer { 230 | if m.msgType != MsgInteractive { 231 | m.err = m.typeError("Template", MsgInteractive) 232 | return m 233 | } 234 | m.message.Content.Template = tempateContent 235 | return m 236 | } 237 | 238 | // Build message and return message body 239 | func (m *MsgBuffer) Build() OutcomingMessage { 240 | return m.message 241 | } 242 | 243 | // Error returns last error 244 | func (m *MsgBuffer) Error() error { 245 | return m.err 246 | } 247 | 248 | // Clear message in buffer 249 | func (m *MsgBuffer) Clear() *MsgBuffer { 250 | m.message = OutcomingMessage{ 251 | MsgType: m.msgType, 252 | } 253 | m.err = nil 254 | return m 255 | } 256 | -------------------------------------------------------------------------------- /msg_buf_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestAttachText(t *testing.T) { 10 | mb := NewMsgBuffer(MsgText) 11 | msg := mb.Text("hello").Build() 12 | assert.Equal(t, "hello", msg.Content.Text.Text) 13 | } 14 | 15 | func TestAttachImage(t *testing.T) { 16 | mb := NewMsgBuffer(MsgImage) 17 | msg := mb.Image("aaaaa").Build() 18 | assert.Equal(t, "aaaaa", msg.Content.Image.ImageKey) 19 | } 20 | 21 | func TestMsgTextBinding(t *testing.T) { 22 | mb := NewMsgBuffer(MsgText) 23 | msg := mb.Text("hello, world").BindEmail(testUserEmail).Build() 24 | assert.Equal(t, "hello, world", msg.Content.Text.Text) 25 | assert.Equal(t, testUserEmail, msg.Email) 26 | } 27 | 28 | func TestBindingUserIDs(t *testing.T) { 29 | mb := NewMsgBuffer(MsgText) 30 | msgEmail := mb.BindEmail(testUserEmail).Build() 31 | assert.Equal(t, testUserEmail, msgEmail.Email) 32 | 33 | mb.Clear() 34 | msgOpenChatID := mb.BindOpenChatID(testGroupChatID).Build() 35 | assert.Equal(t, testGroupChatID, msgOpenChatID.ChatID) 36 | 37 | mb.Clear() 38 | msgUserID := mb.BindUserID("333444").Build() 39 | assert.Equal(t, "333444", msgUserID.UserID) 40 | 41 | mb.Clear() 42 | msgReplyID := mb.BindReply("om_f779ffe0ffa3d1b94fc1ef5fcb6f1063").Build() 43 | assert.Equal(t, "om_f779ffe0ffa3d1b94fc1ef5fcb6f1063", msgReplyID.RootID) 44 | mb.ReplyInThread(true) 45 | assert.False(t, msgReplyID.ReplyInThread) 46 | } 47 | 48 | func TestMsgShareChat(t *testing.T) { 49 | mb := NewMsgBuffer(MsgShareCard) 50 | msg := mb.ShareChat("6559399282837815565").Build() 51 | assert.Equal(t, MsgShareCard, msg.MsgType) 52 | assert.Equal(t, "6559399282837815565", msg.Content.ShareChat.ChatID) 53 | } 54 | 55 | func TestMsgShareUser(t *testing.T) { 56 | mb := NewMsgBuffer(MsgShareUser) 57 | msg := mb.ShareUser("334455").Build() 58 | assert.Equal(t, MsgShareUser, msg.MsgType) 59 | assert.Equal(t, "334455", msg.Content.ShareUser.UserID) 60 | } 61 | 62 | func TestMsgFile(t *testing.T) { 63 | mb := NewMsgBuffer(MsgFile) 64 | msg := mb.File("file_v2_71cafb2c-137f-4bb0-8381-ffd4971dbecg").Build() 65 | assert.Equal(t, MsgFile, msg.MsgType) 66 | assert.Equal(t, "file_v2_71cafb2c-137f-4bb0-8381-ffd4971dbecg", msg.Content.File.FileKey) 67 | } 68 | 69 | func TestMsgAudio(t *testing.T) { 70 | mb := NewMsgBuffer(MsgAudio) 71 | msg := mb.Audio("file_v2_71cafb2c-137f-4bb0-8381-ffd4971dbecg").Build() 72 | assert.Equal(t, MsgAudio, msg.MsgType) 73 | assert.Equal(t, "file_v2_71cafb2c-137f-4bb0-8381-ffd4971dbecg", msg.Content.Audio.FileKey) 74 | } 75 | 76 | func TestMsgMedia(t *testing.T) { 77 | mb := NewMsgBuffer(MsgMedia) 78 | msg := mb.Media("file_v2_b53cd6cc-5327-4968-8bf6-4528deb3068g", "img_v2_b276195a-9ae0-4fec-bbfe-f74b4d5a994g").Build() 79 | assert.Equal(t, MsgMedia, msg.MsgType) 80 | assert.Equal(t, "file_v2_b53cd6cc-5327-4968-8bf6-4528deb3068g", msg.Content.Media.FileKey) 81 | assert.Equal(t, "img_v2_b276195a-9ae0-4fec-bbfe-f74b4d5a994g", msg.Content.Media.ImageKey) 82 | } 83 | 84 | func TestMsgSticker(t *testing.T) { 85 | mb := NewMsgBuffer(MsgSticker) 86 | msg := mb.Sticker("4ba009df-2453-47b3-a753-444b152217bg").Build() 87 | assert.Equal(t, MsgSticker, msg.MsgType) 88 | assert.Equal(t, "4ba009df-2453-47b3-a753-444b152217bg", msg.Content.Sticker.FileKey) 89 | } 90 | 91 | func TestMsgWithWrongType(t *testing.T) { 92 | mb := NewMsgBuffer(MsgText) 93 | mb.ShareChat("6559399282837815565") 94 | assert.Equal(t, mb.Error().Error(), "`ShareChat` is only available to `share_chat`") 95 | mb.ShareUser("334455") 96 | assert.Equal(t, mb.Error().Error(), "`ShareUser` is only available to `share_user`") 97 | mb.Image("aaa") 98 | assert.Equal(t, mb.Error().Error(), "`Image` is only available to `image`") 99 | mb.File("aaa") 100 | assert.Equal(t, mb.Error().Error(), "`File` is only available to `file`") 101 | mb.Audio("aaa") 102 | assert.Equal(t, mb.Error().Error(), "`Audio` is only available to `audio`") 103 | mb.Media("aaa", "bbb") 104 | assert.Equal(t, mb.Error().Error(), "`Media` is only available to `media`") 105 | mb.Sticker("aaa") 106 | assert.Equal(t, mb.Error().Error(), "`Sticker` is only available to `sticker`") 107 | mb.Post(nil) 108 | assert.Equal(t, mb.Error().Error(), "`Post` is only available to `post`") 109 | mb.Card("nil") 110 | assert.Equal(t, mb.Error().Error(), "`Card` is only available to `interactive`") 111 | mbp := NewMsgBuffer(MsgPost) 112 | mbp.Text("hello") 113 | assert.Equal(t, mbp.Error().Error(), "`Text` is only available to `text`") 114 | } 115 | 116 | func TestClearMessage(t *testing.T) { 117 | mb := NewMsgBuffer(MsgText) 118 | mb.Text("hello, world").Build() 119 | assert.Equal(t, "hello, world", mb.message.Content.Text.Text) 120 | mb.Clear() 121 | assert.Equal(t, MsgText, mb.msgType) 122 | assert.Empty(t, mb.message.Content) 123 | mb.Text("attach again").Build() 124 | assert.Equal(t, "attach again", mb.message.Content.Text.Text) 125 | } 126 | 127 | func TestWorkWithTextBuilder(t *testing.T) { 128 | mb := NewMsgBuffer(MsgText) 129 | mb.Text(NewTextBuilder().Textln("hello, world").Render()).Build() 130 | assert.Equal(t, "hello, world\n", mb.message.Content.Text.Text) 131 | } 132 | 133 | func TestWithSign(t *testing.T) { 134 | mb := NewMsgBuffer(MsgText) 135 | assert.Empty(t, mb.message.Sign) 136 | msg := mb.WithSign("xxx", 1661860880).Build() 137 | assert.NotEmpty(t, mb.message.Sign) 138 | assert.Equal(t, "QnWVTSBe6FmQDE0bG6X0mURbI+DnvVyu1h+j5dHOjrU=", msg.Sign) 139 | } 140 | 141 | func TestWithUUID(t *testing.T) { 142 | mb := NewMsgBuffer(MsgText) 143 | assert.Empty(t, mb.message.UUID) 144 | msg := mb.WithUUID("abc-def-0000").Build() 145 | assert.NotEmpty(t, mb.message.UUID) 146 | assert.Equal(t, "abc-def-0000", msg.UUID) 147 | } 148 | -------------------------------------------------------------------------------- /msg_card_builder.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "github.com/go-lark/card-builder" 5 | "github.com/go-lark/card-builder/i18n" 6 | ) 7 | 8 | type i18nCardBuilder struct{} 9 | 10 | // CardBuilder . 11 | type CardBuilder struct { 12 | I18N *i18nCardBuilder 13 | } 14 | 15 | // Card wraps i18n card 16 | func (i18nCardBuilder) Card(blocks ...*i18n.LocalizedBlock) *i18n.Block { 17 | return i18n.Card(blocks...) 18 | } 19 | 20 | func (i18nCardBuilder) WithLocale(locale string, elements ...card.Element) *i18n.LocalizedBlock { 21 | return i18n.WithLocale(locale, elements...) 22 | } 23 | 24 | // Title wraps i18n title block 25 | func (i18nCardBuilder) LocalizedText(locale, s string) *i18n.LocalizedTextBlock { 26 | return i18n.LocalizedText(locale, s) 27 | } 28 | 29 | // NewCardBuilder 新建卡片构造器 30 | func NewCardBuilder() *CardBuilder { 31 | return &CardBuilder{ 32 | I18N: &i18nCardBuilder{}, 33 | } 34 | } 35 | 36 | // Card 包裹了最外层的卡片结构 37 | func (CardBuilder) Card(elements ...card.Element) *card.Block { 38 | return card.Card(elements...) 39 | } 40 | 41 | // Action 交互元素,可添加 Button, SelectMenu, Overflow, DatePicker, TimePicker, DatetimePicker 42 | func (CardBuilder) Action(actions ...card.Element) *card.ActionBlock { 43 | return card.Action(actions...) 44 | } 45 | 46 | // Button 按钮交互元素 47 | func (CardBuilder) Button(text *card.TextBlock) *card.ButtonBlock { 48 | return card.Button(text) 49 | } 50 | 51 | // Confirm 用于交互元素的二次确认 52 | func (CardBuilder) Confirm(title, text string) *card.ConfirmBlock { 53 | return card.Confirm(title, text) 54 | } 55 | 56 | // DatePicker 日期选择器 57 | func (CardBuilder) DatePicker() *card.DatePickerBlock { 58 | return card.DatePicker() 59 | } 60 | 61 | // TimePicker 时间选择器 62 | func (CardBuilder) TimePicker() *card.TimePickerBlock { 63 | return card.TimePicker() 64 | } 65 | 66 | // DatetimePicker 日期时间选择器 67 | func (CardBuilder) DatetimePicker() *card.DatetimePickerBlock { 68 | return card.DatetimePicker() 69 | } 70 | 71 | // Div 内容模块 72 | func (CardBuilder) Div(fields ...*card.FieldBlock) *card.DivBlock { 73 | return card.Div(fields...) 74 | } 75 | 76 | // Field 内容模块的排版元素 77 | func (CardBuilder) Field(text *card.TextBlock) *card.FieldBlock { 78 | return card.Field(text) 79 | } 80 | 81 | // Hr 分割线模块 82 | func (CardBuilder) Hr() *card.HrBlock { 83 | return card.Hr() 84 | } 85 | 86 | // Img 图片展示模块 87 | func (CardBuilder) Img(key string) *card.ImgBlock { 88 | return card.Img(key) 89 | } 90 | 91 | // Note 备注模块 92 | func (CardBuilder) Note() *card.NoteBlock { 93 | return card.Note() 94 | } 95 | 96 | // Option 选项模块,可用于 SelectMenu 和 Overflow 97 | func (CardBuilder) Option(value string) *card.OptionBlock { 98 | return card.Option(value) 99 | } 100 | 101 | // Overflow 折叠按钮菜单组件 102 | func (CardBuilder) Overflow(options ...*card.OptionBlock) *card.OverflowBlock { 103 | return card.Overflow(options...) 104 | } 105 | 106 | // SelectMenu 菜单组件 107 | func (CardBuilder) SelectMenu(options ...*card.OptionBlock) *card.SelectMenuBlock { 108 | return card.SelectMenu(options...) 109 | } 110 | 111 | // Text 文本模块 112 | func (CardBuilder) Text(s string) *card.TextBlock { 113 | return card.Text(s) 114 | } 115 | 116 | // Markdown 单独使用的 Markdown 文本模块 117 | func (CardBuilder) Markdown(s string) *card.MarkdownBlock { 118 | return card.Markdown(s) 119 | } 120 | 121 | // URL 链接模块 122 | func (CardBuilder) URL() *card.URLBlock { 123 | return card.URL() 124 | } 125 | 126 | // ColumnSet column set module 127 | func (CardBuilder) ColumnSet(columns ...*card.ColumnBlock) *card.ColumnSetBlock { 128 | return card.ColumnSet(columns...) 129 | } 130 | 131 | // Column column module 132 | func (CardBuilder) Column(elements ...card.Element) *card.ColumnBlock { 133 | return card.Column(elements...) 134 | } 135 | 136 | // ColumnSetAction column action module 137 | func (CardBuilder) ColumnSetAction(url *card.URLBlock) *card.ColumnSetActionBlock { 138 | return card.ColumnSetAction(url) 139 | } 140 | -------------------------------------------------------------------------------- /msg_post_builder.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // PostContent . 4 | type PostContent map[string]PostBody 5 | 6 | // PostBody . 7 | type PostBody struct { 8 | Title string `json:"title"` 9 | Content [][]PostElem `json:"content"` 10 | } 11 | 12 | // PostElem . 13 | type PostElem struct { 14 | Tag string `json:"tag"` 15 | // For Text 16 | UnEscape *bool `json:"un_escape,omitempty"` 17 | Text *string `json:"text,omitempty"` 18 | Lines *int `json:"lines,omitempty"` 19 | // For Link 20 | Href *string `json:"href,omitempty"` 21 | // For At 22 | UserID *string `json:"user_id,omitempty"` 23 | // For Image 24 | ImageKey *string `json:"image_key,omitempty"` 25 | ImageWidth *int `json:"width,omitempty"` 26 | ImageHeight *int `json:"height,omitempty"` 27 | } 28 | 29 | const ( 30 | msgPostText = "text" 31 | msgPostLink = "a" 32 | msgPostAt = "at" 33 | msgPostImage = "img" 34 | ) 35 | 36 | // PostBuf . 37 | type PostBuf struct { 38 | Title string `json:"title"` 39 | Content []PostElem `json:"content"` 40 | } 41 | 42 | // MsgPostBuilder for build text buf 43 | type MsgPostBuilder struct { 44 | buf map[string]*PostBuf 45 | curLocale string 46 | } 47 | 48 | const defaultLocale = LocaleZhCN 49 | 50 | // NewPostBuilder creates a text builder 51 | func NewPostBuilder() *MsgPostBuilder { 52 | return &MsgPostBuilder{ 53 | buf: make(map[string]*PostBuf), 54 | curLocale: defaultLocale, 55 | } 56 | } 57 | 58 | // Locale renamed to WithLocale but still available 59 | func (pb *MsgPostBuilder) Locale(locale string) *MsgPostBuilder { 60 | return pb.WithLocale(locale) 61 | } 62 | 63 | // WithLocale switches to locale and returns self 64 | func (pb *MsgPostBuilder) WithLocale(locale string) *MsgPostBuilder { 65 | if _, ok := pb.buf[locale]; !ok { 66 | pb.buf[locale] = &PostBuf{} 67 | } 68 | 69 | pb.curLocale = locale 70 | return pb 71 | } 72 | 73 | // CurLocale switches to locale and returns the buffer of that locale 74 | func (pb *MsgPostBuilder) CurLocale() *PostBuf { 75 | return pb.WithLocale(pb.curLocale).buf[pb.curLocale] 76 | } 77 | 78 | // Title sets title 79 | func (pb *MsgPostBuilder) Title(title string) *MsgPostBuilder { 80 | pb.CurLocale().Title = title 81 | return pb 82 | } 83 | 84 | // TextTag creates a text tag 85 | func (pb *MsgPostBuilder) TextTag(text string, lines int, unescape bool) *MsgPostBuilder { 86 | pe := PostElem{ 87 | Tag: msgPostText, 88 | Text: &text, 89 | Lines: &lines, 90 | UnEscape: &unescape, 91 | } 92 | pb.CurLocale().Content = append(pb.CurLocale().Content, pe) 93 | return pb 94 | } 95 | 96 | // LinkTag creates a link tag 97 | func (pb *MsgPostBuilder) LinkTag(text, href string) *MsgPostBuilder { 98 | pe := PostElem{ 99 | Tag: msgPostLink, 100 | Text: &text, 101 | Href: &href, 102 | } 103 | pb.CurLocale().Content = append(pb.CurLocale().Content, pe) 104 | return pb 105 | } 106 | 107 | // AtTag creates an at tag 108 | func (pb *MsgPostBuilder) AtTag(text, userID string) *MsgPostBuilder { 109 | pe := PostElem{ 110 | Tag: msgPostAt, 111 | Text: &text, 112 | UserID: &userID, 113 | } 114 | pb.CurLocale().Content = append(pb.CurLocale().Content, pe) 115 | return pb 116 | } 117 | 118 | // ImageTag creates an image tag 119 | func (pb *MsgPostBuilder) ImageTag(imageKey string, imageWidth, imageHeight int) *MsgPostBuilder { 120 | pe := PostElem{ 121 | Tag: msgPostImage, 122 | ImageKey: &imageKey, 123 | ImageWidth: &imageWidth, 124 | ImageHeight: &imageHeight, 125 | } 126 | pb.CurLocale().Content = append(pb.CurLocale().Content, pe) 127 | return pb 128 | } 129 | 130 | // Clear all message 131 | func (pb *MsgPostBuilder) Clear() { 132 | pb.curLocale = defaultLocale 133 | pb.buf = make(map[string]*PostBuf) 134 | } 135 | 136 | // Render message 137 | func (pb *MsgPostBuilder) Render() *PostContent { 138 | content := make(PostContent) 139 | for locale, buf := range pb.buf { 140 | content[locale] = PostBody{ 141 | Title: buf.Title, 142 | Content: [][]PostElem{buf.Content}, 143 | } 144 | } 145 | return &content 146 | } 147 | 148 | // Len returns buf len 149 | func (pb MsgPostBuilder) Len() int { 150 | return len(pb.CurLocale().Content) 151 | } 152 | -------------------------------------------------------------------------------- /msg_post_builder_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestPostLocale(t *testing.T) { 10 | pb := NewPostBuilder() 11 | assert.Equal(t, defaultLocale, pb.curLocale) 12 | pb.Locale(LocaleEnUS) 13 | assert.Equal(t, LocaleEnUS, pb.curLocale) 14 | pb.WithLocale(LocaleJaJP) 15 | assert.Equal(t, LocaleJaJP, pb.curLocale) 16 | } 17 | 18 | func TestPostTitle(t *testing.T) { 19 | pb := NewPostBuilder() 20 | pb.Title("title") 21 | assert.Equal(t, "title", pb.CurLocale().Title) 22 | } 23 | 24 | func TestPostTextTag(t *testing.T) { 25 | pb := NewPostBuilder() 26 | pb.TextTag("hello, world", 1, true) 27 | buf := pb.CurLocale().Content 28 | assert.Equal(t, "text", buf[0].Tag) 29 | assert.Equal(t, "hello, world", *(buf[0].Text)) 30 | assert.Equal(t, 1, *(buf[0].Lines)) 31 | assert.Equal(t, true, *(buf[0].UnEscape)) 32 | } 33 | 34 | func TestPostLinkTag(t *testing.T) { 35 | pb := NewPostBuilder() 36 | pb.LinkTag("hello, world", "https://www.toutiao.com/") 37 | buf := pb.CurLocale().Content 38 | assert.Equal(t, "a", buf[0].Tag) 39 | assert.Equal(t, "hello, world", *(buf[0].Text)) 40 | assert.Equal(t, "https://www.toutiao.com/", *(buf[0].Href)) 41 | } 42 | 43 | func TestPostAtTag(t *testing.T) { 44 | pb := NewPostBuilder() 45 | pb.AtTag("www", "123456") 46 | buf := pb.CurLocale().Content 47 | assert.Equal(t, "at", buf[0].Tag) 48 | assert.Equal(t, "www", *(buf[0].Text)) 49 | assert.Equal(t, "123456", *(buf[0].UserID)) 50 | } 51 | 52 | func TestPostImgTag(t *testing.T) { 53 | pb := NewPostBuilder() 54 | pb.ImageTag("d9f7d37e-c47c-411b-8ec6-9861132e6986", 320, 240) 55 | buf := pb.CurLocale().Content 56 | assert.Equal(t, "img", buf[0].Tag) 57 | assert.Equal(t, "d9f7d37e-c47c-411b-8ec6-9861132e6986", *(buf[0].ImageKey)) 58 | assert.Equal(t, 240, *(buf[0].ImageHeight)) 59 | assert.Equal(t, 320, *(buf[0].ImageWidth)) 60 | } 61 | 62 | func TestPostClearAndLen(t *testing.T) { 63 | pb := NewPostBuilder() 64 | pb.TextTag("hello, world", 1, true).LinkTag("link", "https://www.toutiao.com/") 65 | assert.Equal(t, 2, pb.Len()) 66 | pb.Clear() 67 | assert.Empty(t, pb.buf) 68 | assert.Equal(t, 0, pb.Len()) 69 | } 70 | 71 | func TestPostMultiLocaleContent(t *testing.T) { 72 | pb := NewPostBuilder() 73 | pb.Title("中文标题") 74 | assert.Equal(t, "中文标题", pb.CurLocale().Title) 75 | pb.TextTag("你好世界", 1, true).TextTag("其他内容", 1, true) 76 | assert.Equal(t, 2, pb.Len()) 77 | 78 | pb.WithLocale(LocaleEnUS).Title("en title") 79 | pb.TextTag("hello, world", 1, true).LinkTag("link", "https://www.toutiao.com/") 80 | assert.Equal(t, 2, pb.Len()) 81 | assert.Equal(t, "en title", pb.CurLocale().Title) 82 | 83 | content := pb.Render() 84 | t.Log(content) 85 | assert.Equal(t, "中文标题", (*content)[LocaleZhCN].Title) 86 | assert.Equal(t, "en title", (*content)[LocaleEnUS].Title) 87 | } 88 | -------------------------------------------------------------------------------- /msg_template_builder.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // MsgTemplateBuilder for build template 4 | type MsgTemplateBuilder struct { 5 | id string 6 | versionName string 7 | data map[string]interface{} 8 | } 9 | 10 | // NewTemplateBuilder creates a text builder 11 | func NewTemplateBuilder() *MsgTemplateBuilder { 12 | return &MsgTemplateBuilder{} 13 | } 14 | 15 | // BindTemplate . 16 | func (tb *MsgTemplateBuilder) BindTemplate(id, versionName string, data map[string]interface{}) *TemplateContent { 17 | tb.id = id 18 | tb.versionName = versionName 19 | tb.data = data 20 | 21 | tc := &TemplateContent{ 22 | Type: "template", 23 | Data: templateData{ 24 | TemplateID: tb.id, 25 | TemplateVersionName: tb.versionName, 26 | }, 27 | } 28 | 29 | if data != nil { 30 | tc.Data.TemplateVariable = tb.data 31 | } 32 | 33 | return tc 34 | } 35 | -------------------------------------------------------------------------------- /msg_template_builder_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestBindTemplate(t *testing.T) { 10 | b := NewTemplateBuilder() 11 | assert.Empty(t, b.id) 12 | assert.Empty(t, b.versionName) 13 | assert.Nil(t, b.data) 14 | 15 | _ = b.BindTemplate("AAqCYI07MQWh1", "1.0.0", map[string]interface{}{ 16 | "name": "志田千陽", 17 | }) 18 | assert.Equal(t, "AAqCYI07MQWh1", b.id) 19 | assert.Equal(t, "1.0.0", b.versionName) 20 | assert.NotEmpty(t, b.data) 21 | } 22 | -------------------------------------------------------------------------------- /msg_text_builder.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // textElemType of a text buf 8 | type textElemType int 9 | 10 | type textElem struct { 11 | elemType textElemType 12 | content string 13 | } 14 | 15 | const ( 16 | // MsgText text only message 17 | msgText textElemType = iota 18 | // MsgAt @somebody 19 | msgAt 20 | // MsgAtAll @all 21 | msgAtAll 22 | // msgSpace space 23 | msgSpace 24 | ) 25 | 26 | // MsgTextBuilder for build text buf 27 | type MsgTextBuilder struct { 28 | buf []textElem 29 | } 30 | 31 | // NewTextBuilder creates a text builder 32 | func NewTextBuilder() *MsgTextBuilder { 33 | return &MsgTextBuilder{ 34 | buf: make([]textElem, 0), 35 | } 36 | } 37 | 38 | // Text add simple texts 39 | func (tb *MsgTextBuilder) Text(text ...interface{}) *MsgTextBuilder { 40 | elem := textElem{ 41 | elemType: msgText, 42 | content: fmt.Sprint(text...), 43 | } 44 | tb.buf = append(tb.buf, elem) 45 | return tb 46 | } 47 | 48 | // Textln add simple texts with a newline 49 | func (tb *MsgTextBuilder) Textln(text ...interface{}) *MsgTextBuilder { 50 | elem := textElem{ 51 | elemType: msgText, 52 | content: fmt.Sprintln(text...), 53 | } 54 | tb.buf = append(tb.buf, elem) 55 | return tb 56 | } 57 | 58 | // Textf add texts with format 59 | func (tb *MsgTextBuilder) Textf(textFmt string, text ...interface{}) *MsgTextBuilder { 60 | elem := textElem{ 61 | elemType: msgText, 62 | content: fmt.Sprintf(textFmt, text...), 63 | } 64 | tb.buf = append(tb.buf, elem) 65 | return tb 66 | } 67 | 68 | // Mention @somebody 69 | func (tb *MsgTextBuilder) Mention(userID string) *MsgTextBuilder { 70 | elem := textElem{ 71 | elemType: msgAt, 72 | content: fmt.Sprintf("@user", userID), 73 | } 74 | tb.buf = append(tb.buf, elem) 75 | return tb 76 | } 77 | 78 | // MentionAll @all 79 | func (tb *MsgTextBuilder) MentionAll() *MsgTextBuilder { 80 | elem := textElem{ 81 | elemType: msgAtAll, 82 | content: "@all", 83 | } 84 | tb.buf = append(tb.buf, elem) 85 | return tb 86 | } 87 | 88 | // Clear all message 89 | func (tb *MsgTextBuilder) Clear() { 90 | tb.buf = make([]textElem, 0) 91 | } 92 | 93 | // Render message 94 | func (tb *MsgTextBuilder) Render() string { 95 | var text string 96 | for _, msg := range tb.buf { 97 | text += msg.content 98 | } 99 | return text 100 | } 101 | 102 | // Len returns buf len 103 | func (tb MsgTextBuilder) Len() int { 104 | return len(tb.buf) 105 | } 106 | -------------------------------------------------------------------------------- /msg_text_builder_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestUseText(t *testing.T) { 10 | tb := NewTextBuilder() 11 | msg := tb.Text("hello, ", "world", 123).Render() 12 | assert.Equal(t, "hello, world123", tb.buf[0].content) 13 | assert.Equal(t, "hello, world123", msg) 14 | } 15 | 16 | func TestTextTextf(t *testing.T) { 17 | tb := NewTextBuilder() 18 | msg := tb.Textf("hello, %s: %d", "world", 1).Render() 19 | assert.Equal(t, "hello, world: 1", tb.buf[0].content) 20 | assert.Equal(t, "hello, world: 1", msg) 21 | } 22 | 23 | func TestTextTextln(t *testing.T) { 24 | tb := NewTextBuilder() 25 | msg := tb.Textln("hello", "world").Render() 26 | assert.Equal(t, "hello world\n", tb.buf[0].content) 27 | assert.Equal(t, "hello world\n", msg) 28 | } 29 | 30 | func TestTextMention(t *testing.T) { 31 | tb := NewTextBuilder() 32 | msg := tb.Text("hello, world").Mention("6454030812462448910").Render() 33 | assert.Equal(t, "hello, world@user", msg) 34 | } 35 | 36 | func TestTextMentionAll(t *testing.T) { 37 | tb := NewTextBuilder() 38 | msg := tb.Text("hello, world").MentionAll().Render() 39 | assert.Equal(t, "hello, world@all", msg) 40 | } 41 | 42 | func TestTextClearAndLen(t *testing.T) { 43 | tb := NewTextBuilder() 44 | tb.Text("hello, world").MentionAll() 45 | assert.Equal(t, 2, tb.Len()) 46 | tb.Clear() 47 | assert.Empty(t, tb.buf) 48 | assert.Equal(t, 0, tb.Len()) 49 | } 50 | -------------------------------------------------------------------------------- /scripts/test_v1.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | echo "mode: atomic" >coverage.txt 5 | 6 | go test -coverprofile=profile.out -covermode=atomic ./... 7 | if [ -f profile.out ]; then 8 | tail -q -n +2 profile.out >>coverage.txt 9 | rm profile.out 10 | fi 11 | 12 | go tool cover -func coverage.txt 13 | -------------------------------------------------------------------------------- /scripts/test_v2.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd ./v2 6 | echo "mode: atomic" >coverage.txt 7 | go test -coverprofile=profile.out -covermode=atomic ./... 8 | if [ -f profile.out ]; then 9 | tail -q -n +2 profile.out >>coverage.txt 10 | rm profile.out 11 | fi 12 | 13 | go tool cover -func coverage.txt 14 | -------------------------------------------------------------------------------- /user.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // UID types 4 | const ( 5 | UIDEmail = "email" 6 | UIDUserID = "user_id" 7 | UIDOpenID = "open_id" 8 | UIDChatID = "chat_id" 9 | UIDUnionID = "union_id" 10 | ) 11 | 12 | // OptionalUserID to contain openID, chatID, userID, email 13 | type OptionalUserID struct { 14 | UIDType string 15 | RealID string 16 | } 17 | 18 | func withOneID(uidType, realID string) *OptionalUserID { 19 | return &OptionalUserID{ 20 | UIDType: uidType, 21 | RealID: realID, 22 | } 23 | } 24 | 25 | // WithEmail uses email as userID 26 | func WithEmail(email string) *OptionalUserID { 27 | return withOneID(UIDEmail, email) 28 | } 29 | 30 | // WithUserID uses userID as userID 31 | func WithUserID(userID string) *OptionalUserID { 32 | return withOneID(UIDUserID, userID) 33 | } 34 | 35 | // WithOpenID uses openID as userID 36 | func WithOpenID(openID string) *OptionalUserID { 37 | return withOneID(UIDOpenID, openID) 38 | } 39 | 40 | // WithChatID uses chatID as userID 41 | func WithChatID(chatID string) *OptionalUserID { 42 | return withOneID(UIDChatID, chatID) 43 | } 44 | 45 | // WithUnionID uses chatID as userID 46 | func WithUnionID(unionID string) *OptionalUserID { 47 | return withOneID(UIDUnionID, unionID) 48 | } 49 | -------------------------------------------------------------------------------- /user_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestWithFunctions(t *testing.T) { 10 | emailUID := WithEmail(testUserEmail) 11 | assert.Equal(t, "email", emailUID.UIDType) 12 | assert.Equal(t, testUserEmail, emailUID.RealID) 13 | 14 | openIDUID := WithOpenID(testUserOpenID) 15 | assert.Equal(t, "open_id", openIDUID.UIDType) 16 | assert.Equal(t, testUserOpenID, openIDUID.RealID) 17 | 18 | chatIDUID := WithChatID(testGroupChatID) 19 | assert.Equal(t, "chat_id", chatIDUID.UIDType) 20 | assert.Equal(t, testGroupChatID, chatIDUID.RealID) 21 | 22 | fakeUID := "6893390418998738946" 23 | userIDUID := WithUserID(fakeUID) 24 | assert.Equal(t, "user_id", userIDUID.UIDType) 25 | assert.Equal(t, fakeUID, userIDUID.RealID) 26 | } 27 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "os" 7 | ) 8 | 9 | // DownloadFile downloads from a URL to local path 10 | func DownloadFile(path, url string) error { 11 | // Create the file 12 | out, err := os.Create(path) 13 | if err != nil { 14 | return err 15 | } 16 | defer out.Close() 17 | 18 | // Get the data 19 | resp, err := http.Get(url) 20 | if err != nil { 21 | return err 22 | } 23 | defer resp.Body.Close() 24 | 25 | // Write the body to file 26 | _, err = io.Copy(out, resp.Body) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /util_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestFileDownload(t *testing.T) { 12 | ts := time.Now().Unix() 13 | filename := fmt.Sprintf("/tmp/go-lark-ci-%d", ts) 14 | err := DownloadFile(filename, "https://s1-fs.pstatp.com/static-resource/v1/363e0009ef09d43d5a96~?image_size=72x72&cut_type=&quality=&format=png&sticker_format=.webp") 15 | if assert.NoError(t, err) { 16 | assert.FileExists(t, filename) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /v2/README.md: -------------------------------------------------------------------------------- 1 | # go-lark 2 | 3 | [![build_v2](https://github.com/go-lark/lark/actions/workflows/build_v2.yml/badge.svg)](https://github.com/go-lark/lark/actions/workflows/build_v2.yml) 4 | -------------------------------------------------------------------------------- /v2/api.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // BaseResponse of an API 4 | type BaseResponse struct { 5 | Code int `json:"code"` 6 | Msg string `json:"msg"` 7 | Error BaseError `json:"error"` 8 | } 9 | 10 | // DummyResponse is used to unmarshal from a complete JSON response but only to retrieve error 11 | type DummyResponse struct { 12 | BaseResponse 13 | } 14 | 15 | // BaseError is returned by the platform 16 | type BaseError struct { 17 | LogID string `json:"log_id,omitempty"` 18 | } 19 | 20 | // I18NNames structure of names in multiple locales 21 | type I18NNames struct { 22 | ZhCN string `json:"zh_cn,omitempty"` 23 | EnUS string `json:"en_us,omitempty"` 24 | JaJP string `json:"ja_jp,omitempty"` 25 | } 26 | 27 | // WithUserIDType assigns user ID type 28 | func (bot *Bot) WithUserIDType(userIDType string) *Bot { 29 | bot.userIDType = userIDType 30 | return bot 31 | } 32 | -------------------------------------------------------------------------------- /v2/api_auth.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // URLs for auth 8 | const ( 9 | tenantAccessTokenInternalURL = "/open-apis/auth/v3/tenant_access_token/internal" 10 | ) 11 | 12 | // TenantAccessTokenInternalResponse . 13 | type TenantAccessTokenInternalResponse struct { 14 | BaseResponse 15 | TenantAccessToken string `json:"tenant_access_token"` 16 | Expire int `json:"expire"` 17 | } 18 | 19 | // GetTenantAccessTokenInternal gets AppAccessToken for internal use 20 | func (bot *Bot) GetTenantAccessTokenInternal(ctx context.Context) (*TenantAccessTokenInternalResponse, error) { 21 | if !bot.requireType(ChatBot) { 22 | return nil, ErrBotTypeError 23 | } 24 | 25 | params := map[string]interface{}{ 26 | "app_id": bot.appID, 27 | "app_secret": bot.appSecret, 28 | } 29 | var respData TenantAccessTokenInternalResponse 30 | err := bot.PostAPIRequest(ctx, "GetTenantAccessTokenInternal", tenantAccessTokenInternalURL, false, params, &respData) 31 | return &respData, err 32 | } 33 | -------------------------------------------------------------------------------- /v2/api_auth_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGetTenantAccessTokenInternal(t *testing.T) { 10 | bot := newTestBot() 11 | resp, err := bot.GetTenantAccessTokenInternal(t.Context()) 12 | if assert.NoError(t, err) { 13 | assert.Equal(t, 0, resp.Code) 14 | assert.NotEmpty(t, resp.TenantAccessToken) 15 | assert.NotEmpty(t, resp.Expire) 16 | t.Log(resp) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /v2/api_bot.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import "context" 4 | 5 | const ( 6 | getBotInfoURL = "/open-apis/bot/v3/info/" 7 | ) 8 | 9 | // GetBotInfoResponse . 10 | type GetBotInfoResponse struct { 11 | BaseResponse 12 | Bot struct { 13 | ActivateStatus int `json:"activate_status"` 14 | AppName string `json:"app_name"` 15 | AvatarURL string `json:"avatar_url"` 16 | IPWhiteList []string `json:"ip_white_list"` 17 | OpenID string `json:"open_id"` 18 | } `json:"bot"` 19 | } 20 | 21 | // GetBotInfo returns bot info 22 | func (bot Bot) GetBotInfo(ctx context.Context) (*GetBotInfoResponse, error) { 23 | var respData GetBotInfoResponse 24 | err := bot.PostAPIRequest(ctx, "GetBotInfo", getBotInfoURL, true, nil, &respData) 25 | return &respData, err 26 | } 27 | -------------------------------------------------------------------------------- /v2/api_bot_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGetBotInfo(t *testing.T) { 10 | resp, err := bot.GetBotInfo(t.Context()) 11 | if assert.NoError(t, err) { 12 | assert.Equal(t, 0, resp.Code) 13 | assert.Equal(t, "go-lark-bot", resp.Bot.AppName) 14 | t.Log(resp) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /v2/api_buzz_message.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | const ( 9 | buzzInAppURL = "/open-apis/im/v1/messages/%s/urgent_app?user_id_type=%s" 10 | buzzSMSURL = "/open-apis/im/v1/messages/%s/urgent_sms?user_id_type=%s" 11 | buzzPhoneURL = "/open-apis/im/v1/messages/%s/urgent_phone?user_id_type=%s" 12 | ) 13 | 14 | // Buzz types 15 | const ( 16 | BuzzTypeInApp = "buzz_inapp" 17 | BuzzTypeSMS = "buzz_sms" 18 | BuzzTypePhone = "buzz_phone" 19 | ) 20 | 21 | // BuzzMessageResponse . 22 | type BuzzMessageResponse struct { 23 | BaseResponse 24 | 25 | Data struct { 26 | InvalidUserIDList []string `json:"invalid_user_id_list,omitempty"` 27 | } `json:"data,omitempty"` 28 | } 29 | 30 | // BuzzMessage . 31 | func (bot Bot) BuzzMessage(ctx context.Context, buzzType string, messageID string, userIDList ...string) (*BuzzMessageResponse, error) { 32 | var respData BuzzMessageResponse 33 | url := buzzInAppURL 34 | switch buzzType { 35 | case BuzzTypeInApp: 36 | url = buzzInAppURL 37 | case BuzzTypeSMS: 38 | url = buzzSMSURL 39 | case BuzzTypePhone: 40 | url = buzzPhoneURL 41 | } 42 | req := map[string][]string{ 43 | "user_id_list": userIDList, 44 | } 45 | err := bot.PatchAPIRequest(ctx, "BuzzMessage", fmt.Sprintf(url, messageID, bot.userIDType), true, req, &respData) 46 | return &respData, err 47 | } 48 | -------------------------------------------------------------------------------- /v2/api_buzz_message_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestBuzzMessage(t *testing.T) { 10 | resp, err := bot.PostText(t.Context(), "this text will be buzzed", WithEmail(testUserEmail)) 11 | if assert.NoError(t, err) { 12 | bot.WithUserIDType(UIDOpenID) 13 | messageID := resp.Data.MessageID 14 | buzzResp, err := bot.BuzzMessage(t.Context(), BuzzTypeInApp, messageID, testUserOpenID) 15 | if assert.NoError(t, err) { 16 | assert.Equal(t, 0, buzzResp.Code) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /v2/api_chat_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestChatInfo(t *testing.T) { 13 | bot.WithUserIDType(UIDOpenID) 14 | assert.Equal(t, UIDOpenID, bot.userIDType) 15 | resp, err := bot.GetChat(t.Context(), testGroupChatID) 16 | if assert.NoError(t, err) { 17 | assert.Equal(t, 0, resp.Code) 18 | assert.Equal(t, "go-lark-ci", resp.Data.Name) 19 | assert.Equal(t, "group", resp.Data.ChatMode) 20 | assert.Equal(t, testUserOpenID, resp.Data.OwnerID) 21 | t.Log(resp.Data) 22 | } 23 | } 24 | 25 | func TestChatList(t *testing.T) { 26 | bot.WithUserIDType(UIDOpenID) 27 | assert.Equal(t, UIDOpenID, bot.userIDType) 28 | resp, err := bot.ListChat(t.Context(), "ByCreateTimeAsc", "", 10) 29 | if assert.NoError(t, err) { 30 | assert.Equal(t, 0, resp.Code) 31 | assert.NotEmpty(t, resp.Data.Items) 32 | t.Log(resp.Data.Items[0]) 33 | } 34 | } 35 | 36 | func TestChatSearch(t *testing.T) { 37 | bot.WithUserIDType(UIDOpenID) 38 | assert.Equal(t, UIDOpenID, bot.userIDType) 39 | resp, err := bot.SearchChat(t.Context(), "go-lark", "", 10) 40 | if assert.NoError(t, err) { 41 | assert.Equal(t, 0, resp.Code) 42 | if assert.NotEmpty(t, resp.Data.Items) { 43 | for _, item := range resp.Data.Items { 44 | if !strings.Contains(item.Name, "go-lark") { 45 | t.Error(item.Name, "does not contain go-lark") 46 | } 47 | } 48 | } 49 | t.Log(resp.Data.Items) 50 | } 51 | } 52 | 53 | func TestChatCRUD(t *testing.T) { 54 | bot.WithUserIDType(UIDOpenID) 55 | resp, err := bot.CreateChat( 56 | t.Context(), 57 | CreateChatRequest{ 58 | Name: fmt.Sprintf("go-lark-ci-%d", time.Now().Unix()), 59 | ChatMode: "group", 60 | ChatType: "public", 61 | }) 62 | if assert.NoError(t, err) { 63 | chatID := resp.Data.ChatID 64 | assert.NotEmpty(t, chatID) 65 | upResp, err := bot.UpdateChat( 66 | t.Context(), 67 | chatID, 68 | UpdateChatRequest{ 69 | Description: "new description", 70 | }) 71 | t.Log(upResp) 72 | if assert.NoError(t, err) { 73 | getResp, err := bot.GetChat(t.Context(), chatID) 74 | if assert.NoError(t, err) { 75 | assert.Equal(t, "new description", getResp.Data.Description) 76 | // join chat 77 | joinResp, err := bot.JoinChat(t.Context(), chatID) 78 | assert.Zero(t, joinResp.Code) 79 | assert.NoError(t, err) 80 | 81 | // add chat member 82 | addMemberResp, err := bot.AddChatMember( 83 | t.Context(), 84 | chatID, 85 | []string{testUserOpenID}) 86 | if assert.NoError(t, err) { 87 | assert.Equal(t, 0, addMemberResp.Code) 88 | assert.Empty(t, addMemberResp.Data.InvalidIDList) 89 | } 90 | // remove chat member 91 | removeMemberResp, err := bot.RemoveChatMember(t.Context(), chatID, []string{testUserOpenID}) 92 | if assert.NoError(t, err) { 93 | assert.Equal(t, 0, removeMemberResp.Code) 94 | assert.Empty(t, removeMemberResp.Data.InvalidIDList) 95 | } 96 | 97 | // delete 98 | _, err = bot.DeleteChat(t.Context(), chatID) 99 | assert.NoError(t, err) 100 | } 101 | } 102 | } 103 | } 104 | 105 | func TestIsInChat(t *testing.T) { 106 | resp, err := bot.IsInChat(t.Context(), testGroupChatID) 107 | if assert.NoError(t, err) { 108 | assert.Equal(t, 0, resp.Code) 109 | assert.True(t, resp.Data.IsInChat) 110 | } 111 | } 112 | 113 | func TestGetChatMembers(t *testing.T) { 114 | bot.WithUserIDType(UIDOpenID) 115 | resp, err := bot.GetChatMembers(t.Context(), testGroupChatID, "", 1) 116 | if assert.NoError(t, err) { 117 | assert.Equal(t, 0, resp.Code) 118 | assert.NotEmpty(t, resp.Data.Items) 119 | assert.Empty(t, resp.Data.PageToken) 120 | assert.NotEmpty(t, resp.Data.MemberTotal) 121 | assert.False(t, resp.Data.HasMore) 122 | } 123 | } 124 | 125 | func TestChatTopNotice(t *testing.T) { 126 | resp, err := bot.PostText(t.Context(), "group notice", WithChatID(testGroupChatID)) 127 | if assert.NoError(t, err) { 128 | setResp, _ := bot.SetTopNotice(t.Context(), testGroupChatID, "2", resp.Data.MessageID) 129 | assert.Equal(t, 0, setResp.Code) 130 | delResp, _ := bot.DeleteTopNotice(t.Context(), testGroupChatID) 131 | assert.Equal(t, 0, delResp.Code) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /v2/api_contact.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | ) 8 | 9 | const ( 10 | getUserInfoURL = "/open-apis/contact/v3/users/%s?user_id_type=%s" 11 | batchGetUserInfoURL = "/open-apis/contact/v3/users/batch?%s" 12 | ) 13 | 14 | // GetUserInfoResponse . 15 | type GetUserInfoResponse struct { 16 | BaseResponse 17 | Data struct { 18 | User UserInfo 19 | } 20 | } 21 | 22 | // BatchGetUserInfoResponse . 23 | type BatchGetUserInfoResponse struct { 24 | BaseResponse 25 | Data struct { 26 | Items []UserInfo 27 | } 28 | } 29 | 30 | // UserInfo . 31 | type UserInfo struct { 32 | OpenID string `json:"open_id,omitempty"` 33 | Email string `json:"email,omitempty"` 34 | UserID string `json:"user_id,omitempty"` 35 | ChatID string `json:"chat_id,omitempty"` 36 | UnionID string `json:"union_id,omitempty"` 37 | Name string `json:"name,omitempty"` 38 | EnglishName string `json:"en_name,omitempty"` 39 | NickName string `json:"nickname,omitempty"` 40 | Mobile string `json:"mobile,omitempty"` 41 | MobileVisible bool `json:"mobile_visible,omitempty"` 42 | Gender int `json:"gender,omitempty"` 43 | Avatar UserAvatar `json:"avatar,omitempty"` 44 | Status UserStatus `json:"status,omitempty"` 45 | City string `json:"city,omitempty"` 46 | Country string `json:"country,omitempty"` 47 | WorkStation string `json:"work_station,omitempty"` 48 | JoinTime int `json:"join_time,omitempty"` 49 | EmployeeNo string `json:"employee_no,omitempty"` 50 | EmployeeType int `json:"employee_type,omitempty"` 51 | EnterpriseEmail string `json:"enterprise_email,omitempty"` 52 | Geo string `json:"geo,omitempty"` 53 | JobTitle string `json:"job_title,omitempty"` 54 | JobLevelID string `json:"job_level_id,omitempty"` 55 | JobFamilyID string `json:"job_family_id,omitempty"` 56 | DepartmentIDs []string `json:"department_ids,omitempty"` 57 | LeaderUserID string `json:"leader_user_id,omitempty"` 58 | IsTenantManager bool `json:"is_tenant_manager,omitempty"` 59 | } 60 | 61 | // UserAvatar . 62 | type UserAvatar struct { 63 | Avatar72 string `json:"avatar_72,omitempty"` 64 | Avatar240 string `json:"avatar_240,omitempty"` 65 | Avatar640 string `json:"avatar_640,omitempty"` 66 | AvatarOrigin string `json:"avatar_origin,omitempty"` 67 | } 68 | 69 | // UserStatus . 70 | type UserStatus struct { 71 | IsFrozen bool 72 | IsResigned bool 73 | IsActivated bool 74 | IsExited bool 75 | IsUnjoin bool 76 | } 77 | 78 | // GetUserInfo gets contact info 79 | func (bot Bot) GetUserInfo(ctx context.Context, userID *OptionalUserID) (*GetUserInfoResponse, error) { 80 | url := fmt.Sprintf(getUserInfoURL, userID.RealID, userID.UIDType) 81 | var respData GetUserInfoResponse 82 | err := bot.GetAPIRequest(ctx, "GetUserInfo", url, true, nil, &respData) 83 | return &respData, err 84 | } 85 | 86 | // BatchGetUserInfo gets contact info in batch 87 | func (bot Bot) BatchGetUserInfo(ctx context.Context, userIDType string, userIDs ...string) (*BatchGetUserInfoResponse, error) { 88 | if len(userIDs) == 0 || len(userIDs) > 50 { 89 | return nil, ErrParamExceedInputLimit 90 | } 91 | v := url.Values{} 92 | v.Set("user_id_type", userIDType) 93 | for _, userID := range userIDs { 94 | v.Add("user_ids", userID) 95 | } 96 | url := fmt.Sprintf(batchGetUserInfoURL, v.Encode()) 97 | var respData BatchGetUserInfoResponse 98 | err := bot.GetAPIRequest(ctx, "GetUserInfo", url, true, nil, &respData) 99 | return &respData, err 100 | } 101 | -------------------------------------------------------------------------------- /v2/api_contact_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGetUserInfo(t *testing.T) { 10 | resp, err := bot.GetUserInfo(t.Context(), WithUserID(testUserID)) 11 | if assert.NoError(t, err) { 12 | assert.Equal(t, resp.Data.User.Name, "David") 13 | } 14 | bresp, err := bot.BatchGetUserInfo(t.Context(), UIDUserID, testUserID) 15 | if assert.NoError(t, err) { 16 | if assert.NotEmpty(t, bresp.Data.Items) { 17 | assert.Equal(t, bresp.Data.Items[0].Name, "David") 18 | } 19 | } 20 | _, err = bot.BatchGetUserInfo(t.Context(), UIDUserID) 21 | assert.ErrorIs(t, err, ErrParamExceedInputLimit) 22 | } 23 | -------------------------------------------------------------------------------- /v2/api_notification.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import "context" 4 | 5 | // PostNotificationResp . 6 | type PostNotificationResp struct { 7 | Code int `json:"code"` 8 | Msg string `json:"msg"` 9 | StatusCode int `json:"StatusCode"` 10 | StatusMessage string `json:"StatusMessage"` 11 | } 12 | 13 | // PostNotification posts nofication to a given webhook 14 | func (bot Bot) PostNotification(ctx context.Context, om OutcomingMessage) (*PostNotificationResp, error) { 15 | if !bot.requireType(NotificationBot) { 16 | return nil, ErrBotTypeError 17 | } 18 | 19 | params := buildNotification(om) 20 | var respData PostNotificationResp 21 | err := bot.PostAPIRequest(ctx, "PostNotification", bot.webhook, false, params, &respData) 22 | return &respData, err 23 | } 24 | -------------------------------------------------------------------------------- /v2/api_notification_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestWebhook(t *testing.T) { 11 | bot := NewNotificationBot(testWebhook) 12 | 13 | mbText := NewMsgBuffer(MsgText) 14 | mbText.Text("hello") 15 | resp, err := bot.PostNotification(t.Context(), mbText.Build()) 16 | assert.NoError(t, err) 17 | assert.Zero(t, resp.StatusCode) 18 | assert.Equal(t, "success", resp.StatusMessage) 19 | 20 | mbPost := NewMsgBuffer(MsgPost) 21 | mbPost.Post(NewPostBuilder().Title("hello").TextTag("world", 1, true).Render()) 22 | resp, err = bot.PostNotification(t.Context(), mbPost.Build()) 23 | assert.NoError(t, err) 24 | assert.Zero(t, resp.StatusCode) 25 | assert.Equal(t, "success", resp.StatusMessage) 26 | 27 | mbImg := NewMsgBuffer(MsgImage) 28 | mbImg.Image("img_a97c1597-9c0a-47c1-9fb4-dd3e5e37ac9g") 29 | resp, err = bot.PostNotification(t.Context(), mbImg.Build()) 30 | assert.NoError(t, err) 31 | assert.Zero(t, resp.StatusCode) 32 | assert.Equal(t, "success", resp.StatusMessage) 33 | 34 | mbShareGroup := NewMsgBuffer(MsgShareCard) 35 | mbShareGroup.ShareChat(testGroupChatID) 36 | resp, err = bot.PostNotification(t.Context(), mbShareGroup.Build()) 37 | assert.NoError(t, err) 38 | assert.Zero(t, resp.StatusCode) 39 | assert.Equal(t, "success", resp.StatusMessage) 40 | } 41 | 42 | func TestWebhookCardMessage(t *testing.T) { 43 | bot := NewNotificationBot(testWebhook) 44 | 45 | b := NewCardBuilder() 46 | card := b.Card( 47 | b.Div( 48 | b.Field(b.Text("左侧内容")).Short(), 49 | b.Field(b.Text("右侧内容")).Short(), 50 | b.Field(b.Text("整排内容")), 51 | b.Field(b.Text("整排**Markdown**内容").LarkMd()), 52 | ), 53 | b.Div(). 54 | Text(b.Text("Text Content")). 55 | Extra(b.Img("img_a7c6aa35-382a-48ad-839d-d0182a69b4dg")), 56 | b.Note(). 57 | AddText(b.Text("Note **Text**").LarkMd()). 58 | AddImage(b.Img("img_a7c6aa35-382a-48ad-839d-d0182a69b4dg")), 59 | ). 60 | Wathet(). 61 | Title("Notification Card") 62 | msg := NewMsgBuffer(MsgInteractive) 63 | om := msg.Card(card.String()).Build() 64 | resp, err := bot.PostNotification(t.Context(), om) 65 | 66 | if assert.NoError(t, err) { 67 | assert.Equal(t, 0, resp.StatusCode) 68 | assert.NotEmpty(t, resp.StatusMessage) 69 | } 70 | } 71 | 72 | func TestWebhookSigned(t *testing.T) { 73 | bot := NewNotificationBot(testWebhookSigned) 74 | 75 | mbText := NewMsgBuffer(MsgText) 76 | mbText.Text("hello sign").WithSign("FT1dnAgPYYTcpafMTkhPjc", time.Now().Unix()) 77 | resp, err := bot.PostNotification(t.Context(), mbText.Build()) 78 | assert.NoError(t, err) 79 | assert.Zero(t, resp.StatusCode) 80 | assert.Equal(t, "success", resp.StatusMessage) 81 | } 82 | 83 | func TestWebhookSignedError(t *testing.T) { 84 | bot := NewNotificationBot("https://open.feishu.cn/open-apis/bot/v2/hook/749be902-6eaa-4cc3-9325-be4126164b02") 85 | 86 | mbText := NewMsgBuffer(MsgText) 87 | mbText.Text("hello sign").WithSign("LIpnNexV7rwOyOebKoqSdb", time.Now().Unix()) 88 | resp, err := bot.PostNotification(t.Context(), mbText.Build()) 89 | assert.Error(t, err) 90 | assert.Zero(t, resp.StatusCode) 91 | assert.Equal(t, "sign match fail or timestamp is not within one hour from current time", resp.Msg) 92 | } 93 | -------------------------------------------------------------------------------- /v2/crypto.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/hmac" 7 | "crypto/sha256" 8 | "encoding/base64" 9 | "errors" 10 | "fmt" 11 | ) 12 | 13 | // EncryptKey . 14 | func EncryptKey(key string) []byte { 15 | sha256key := sha256.Sum256([]byte(key)) 16 | return sha256key[:sha256.Size] 17 | } 18 | 19 | // Decrypt with AES Cipher 20 | func Decrypt(encryptedKey []byte, data string) ([]byte, error) { 21 | block, err := aes.NewCipher(encryptedKey) 22 | if err != nil { 23 | return nil, err 24 | } 25 | ciphertext, err := base64.StdEncoding.DecodeString(data) 26 | iv := encryptedKey[:aes.BlockSize] 27 | blockMode := cipher.NewCBCDecrypter(block, iv) 28 | decryptedData := make([]byte, len(data)) 29 | blockMode.CryptBlocks(decryptedData, ciphertext) 30 | msg := unpad(decryptedData) 31 | if len(msg) < block.BlockSize() { 32 | return nil, errors.New("msg length is less than blocksize") 33 | } 34 | return msg[block.BlockSize():], err 35 | } 36 | 37 | func unpad(data []byte) []byte { 38 | length := len(data) 39 | var unpadding, unpaddingIdx int 40 | for i := length - 1; i > 0; i-- { 41 | if data[i] != 0 { 42 | unpadding = int(data[i]) 43 | unpaddingIdx = length - 1 - i 44 | break 45 | } 46 | } 47 | return data[:(length - unpaddingIdx - unpadding)] 48 | } 49 | 50 | // GenSign generates sign for notification bot 51 | func GenSign(secret string, timestamp int64) (string, error) { 52 | stringToSign := fmt.Sprintf("%v", timestamp) + "\n" + secret 53 | 54 | var data []byte 55 | h := hmac.New(sha256.New, []byte(stringToSign)) 56 | _, err := h.Write(data) 57 | if err != nil { 58 | return "", err 59 | } 60 | 61 | signature := base64.StdEncoding.EncodeToString(h.Sum(nil)) 62 | return signature, nil 63 | } 64 | -------------------------------------------------------------------------------- /v2/crypto_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestAESDecrypt(t *testing.T) { 10 | key := EncryptKey("test key") 11 | data, _ := Decrypt(key, "P37w+VZImNgPEO1RBhJ6RtKl7n6zymIbEG1pReEzghk=") 12 | assert.Equal(t, "hello world", string(data)) 13 | assert.Equal(t, 11, len(data)) 14 | } 15 | 16 | func TestGenSign(t *testing.T) { 17 | sign, err := GenSign("xxx", 1661860880) 18 | if assert.NoError(t, err) { 19 | assert.Equal(t, "QnWVTSBe6FmQDE0bG6X0mURbI+DnvVyu1h+j5dHOjrU=", sign) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /v2/error.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | // Errors 9 | var ( 10 | ErrBotTypeError = errors.New("Bot type error") 11 | ErrParamUserID = errors.New("Param error: UserID") 12 | ErrParamMessageID = errors.New("Param error: Message ID") 13 | ErrParamExceedInputLimit = errors.New("Param error: Exceed input limit") 14 | ErrMessageTypeNotSuppored = errors.New("Message type not supported") 15 | ErrEncryptionNotEnabled = errors.New("Encryption is not enabled") 16 | ErrMessageNotBuild = errors.New("Message not build") 17 | ErrUnsupportedUIDType = errors.New("Unsupported UID type") 18 | ErrInvalidReceiveID = errors.New("Invalid receive ID") 19 | ErrEventTypeNotMatch = errors.New("Event type not match") 20 | ErrMessageType = errors.New("Message type error") 21 | ) 22 | 23 | // APIError constructs an error with given response 24 | func APIError(url string, resp BaseResponse) error { 25 | return fmt.Errorf( 26 | "Lark API server returned error: Code [%d] Message [%s] LogID [%s] Interface [%s]", 27 | resp.Code, 28 | resp.Msg, 29 | resp.Error.LogID, 30 | url, 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /v2/event.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "log" 7 | "net/http" 8 | ) 9 | 10 | // EventType definitions 11 | const ( 12 | EventTypeMessageReceived = "im.message.receive_v1" 13 | EventTypeMessageRead = "im.message.message_read_v1" 14 | EventTypeMessageRecalled = "im.message.recalled_v1" 15 | EventTypeMessageReactionCreated = "im.message.reaction.created_v1" 16 | EventTypeMessageReactionDeleted = "im.message.reaction.deleted_v1" 17 | EventTypeChatDisbanded = "im.chat.disbanded_v1" 18 | EventTypeUserAdded = "im.chat.member.user.added_v1" 19 | EventTypeUserDeleted = "im.chat.member.user.deleted_v1" 20 | EventTypeBotAdded = "im.chat.member.bot.added_v1" 21 | EventTypeBotDeleted = "im.chat.member.bot.deleted_v1" 22 | // not supported yet 23 | EventTypeChatUpdated = "im.chat.updated_v1" 24 | EventTypeUserWithdrawn = "im.chat.member.user.withdrawn_v1" 25 | ) 26 | 27 | // Event handles events with v2 schema 28 | type Event struct { 29 | Schema string `json:"schema,omitempty"` 30 | Header EventHeader `json:"header,omitempty"` 31 | 32 | EventRaw json.RawMessage `json:"event,omitempty"` 33 | Event interface{} `json:"-"` 34 | } 35 | 36 | // EventHeader . 37 | type EventHeader struct { 38 | EventID string `json:"event_id,omitempty"` 39 | EventType string `json:"event_type,omitempty"` 40 | CreateTime string `json:"create_time,omitempty"` 41 | Token string `json:"token,omitempty"` 42 | AppID string `json:"app_id,omitempty"` 43 | TenantKey string `json:"tenant_key,omitempty"` 44 | } 45 | 46 | // EventBody . 47 | type EventBody struct { 48 | Type string `json:"type"` 49 | AppID string `json:"app_id"` 50 | TenantKey string `json:"tenant_key"` 51 | ChatType string `json:"chat_type"` 52 | MsgType string `json:"msg_type"` 53 | RootID string `json:"root_id,omitempty"` 54 | ParentID string `json:"parent_id,omitempty"` 55 | OpenID string `json:"open_id,omitempty"` 56 | OpenChatID string `json:"open_chat_id,omitempty"` 57 | OpenMessageID string `json:"open_message_id,omitempty"` 58 | IsMention bool `json:"is_mention,omitempty"` 59 | Title string `json:"title,omitempty"` 60 | Text string `json:"text,omitempty"` 61 | RealText string `json:"text_without_at_bot,omitempty"` 62 | ImageKey string `json:"image_key,omitempty"` 63 | ImageURL string `json:"image_url,omitempty"` 64 | FileKey string `json:"file_key,omitempty"` 65 | } 66 | 67 | // EventUserID . 68 | type EventUserID struct { 69 | UnionID string `json:"union_id,omitempty"` 70 | UserID string `json:"user_id,omitempty"` 71 | OpenID string `json:"open_id,omitempty"` 72 | } 73 | 74 | // PostEvent posts events 75 | func (e Event) PostEvent(client *http.Client, hookURL string) (*http.Response, error) { 76 | buf := new(bytes.Buffer) 77 | err := json.NewEncoder(buf).Encode(e) 78 | if err != nil { 79 | log.Printf("Encode json failed: %+v\n", err) 80 | return nil, err 81 | } 82 | resp, err := client.Post(hookURL, "application/json; charset=utf-8", buf) 83 | return resp, err 84 | } 85 | 86 | // GetEvent . 87 | func (e Event) GetEvent(eventType string, body interface{}) error { 88 | if e.Header.EventType != eventType { 89 | return ErrEventTypeNotMatch 90 | } 91 | err := json.Unmarshal(e.EventRaw, &body) 92 | return err 93 | } 94 | -------------------------------------------------------------------------------- /v2/event_bot_added.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // EventBotAdded . 4 | type EventBotAdded struct { 5 | ChatID string `json:"chat_id,omitempty"` 6 | OperatorID EventUserID `json:"operator_id,omitempty"` 7 | External bool `json:"external,omitempty"` 8 | OperatorTenantKey string `json:"operator_tenant_key,omitempty"` 9 | } 10 | 11 | // GetBotAdded . 12 | func (e Event) GetBotAdded() (*EventBotAdded, error) { 13 | var body EventBotAdded 14 | err := e.GetEvent(EventTypeBotAdded, &body) 15 | return &body, err 16 | } 17 | -------------------------------------------------------------------------------- /v2/event_bot_deleted.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // EventBotDeleted . 4 | type EventBotDeleted = EventBotAdded 5 | 6 | // GetBotDeleted . 7 | func (e Event) GetBotDeleted() (*EventBotDeleted, error) { 8 | var body EventBotDeleted 9 | err := e.GetEvent(EventTypeBotDeleted, &body) 10 | return &body, err 11 | } 12 | -------------------------------------------------------------------------------- /v2/event_challenge.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import "encoding/json" 4 | 5 | // EventChallenge is request of add event hook 6 | type EventChallenge struct { 7 | Token string `json:"token,omitempty"` 8 | Challenge string `json:"challenge,omitempty"` 9 | Type string `json:"type,omitempty"` 10 | } 11 | 12 | // EncryptedReq is request of encrypted challenge 13 | type EncryptedReq struct { 14 | Encrypt string `json:"encrypt,omitempty"` 15 | } 16 | 17 | // EventCardCallback is request of card 18 | type EventCardCallback struct { 19 | AppID string `json:"app_id,omitempty"` 20 | TenantKey string `json:"tenant_key,omitempty"` 21 | Token string `json:"token,omitempty"` 22 | OpenID string `json:"open_id,omitempty"` 23 | UserID string `json:"user_id,omitempty"` 24 | MessageID string `json:"open_message_id,omitempty"` 25 | ChatID string `json:"open_chat_id,omitempty"` 26 | Action EventCardAction `json:"action,omitempty"` 27 | } 28 | 29 | // EventCardAction . 30 | type EventCardAction struct { 31 | Tag string `json:"tag,omitempty"` // button, overflow, select_static, select_person, &datepicker 32 | Option string `json:"option,omitempty"` // only for Overflow and SelectMenu 33 | Timezone string `json:"timezone,omitempty"` // only for DatePicker 34 | Value json.RawMessage `json:"value,omitempty"` // for any elements with value 35 | } 36 | -------------------------------------------------------------------------------- /v2/event_chat_disbanded.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // EventChatDisbanded . 4 | type EventChatDisbanded struct { 5 | ChatID string `json:"chat_id,omitempty"` 6 | OperatorID EventUserID `json:"operator_id,omitempty"` 7 | External bool `json:"external,omitempty"` 8 | OperatorTenantKey string `json:"operator_tenant_key,omitempty"` 9 | } 10 | 11 | // GetChatDisbanded . 12 | func (e Event) GetChatDisbanded() (*EventChatDisbanded, error) { 13 | var body EventChatDisbanded 14 | err := e.GetEvent(EventTypeChatDisbanded, &body) 15 | return &body, err 16 | } 17 | -------------------------------------------------------------------------------- /v2/event_message_reaction_created.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // EventMessageReactionCreated . 4 | type EventMessageReactionCreated struct { 5 | MessageID string `json:"message_id,omitempty"` 6 | OperatorType string `json:"operator_type,omitempty"` 7 | UserID EventUserID `json:"user_id,omitempty"` 8 | AppID string `json:"app_id,omitempty"` 9 | ActionTime string `json:"action_time,omitempty"` 10 | ReactionType struct { 11 | EmojiType string `json:"emoji_type,omitempty"` 12 | } `json:"reaction_type,omitempty"` 13 | } 14 | 15 | // GetMessageReactionCreated . 16 | func (e Event) GetMessageReactionCreated() (*EventMessageReactionCreated, error) { 17 | var body EventMessageReactionCreated 18 | err := e.GetEvent(EventTypeMessageReactionCreated, &body) 19 | return &body, err 20 | } 21 | -------------------------------------------------------------------------------- /v2/event_message_reaction_deleted.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // EventMessageReactionDeleted . 4 | type EventMessageReactionDeleted struct { 5 | MessageID string `json:"message_id,omitempty"` 6 | OperatorType string `json:"operator_type,omitempty"` 7 | UserID EventUserID `json:"user_id,omitempty"` 8 | AppID string `json:"app_id,omitempty"` 9 | ActionTime string `json:"action_time,omitempty"` 10 | ReactionType struct { 11 | EmojiType string `json:"emoji_type,omitempty"` 12 | } `json:"reaction_type,omitempty"` 13 | } 14 | 15 | // GetMessageReactionDeleted . 16 | func (e Event) GetMessageReactionDeleted() (*EventMessageReactionDeleted, error) { 17 | var body EventMessageReactionDeleted 18 | err := e.GetEvent(EventTypeMessageReactionDeleted, &body) 19 | return &body, err 20 | } 21 | -------------------------------------------------------------------------------- /v2/event_message_read.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // EventMessageRead . 4 | type EventMessageRead struct { 5 | Reader struct { 6 | ReaderID EventUserID `json:"reader_id,omitempty"` 7 | ReadTime string `json:"read_time,omitempty"` 8 | TenantKey string `json:"tenant_key,omitempty"` 9 | } `json:"reader,omitempty"` 10 | MessageIDList []string `json:"message_id_list,omitempty"` 11 | } 12 | 13 | // GetMessageRead . 14 | func (e Event) GetMessageRead() (*EventMessageRead, error) { 15 | var body EventMessageRead 16 | err := e.GetEvent(EventTypeMessageRead, &body) 17 | return &body, err 18 | } 19 | -------------------------------------------------------------------------------- /v2/event_message_recalled.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // EventMessageRecalled . 4 | type EventMessageRecalled struct { 5 | MessageID string `json:"message_id,omitempty"` 6 | ChatID string `json:"chat_id,omitempty"` 7 | RecallTime string `json:"recall_time,omitempty"` 8 | RecallType string `json:"recall_type,omitempty"` 9 | } 10 | 11 | // GetMessageRecalled . 12 | func (e Event) GetMessageRecalled() (*EventMessageRecalled, error) { 13 | var body EventMessageRecalled 14 | err := e.GetEvent(EventTypeMessageRecalled, &body) 15 | return &body, err 16 | } 17 | -------------------------------------------------------------------------------- /v2/event_message_received.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // EventMessageReceived . 4 | type EventMessageReceived struct { 5 | Sender struct { 6 | SenderID EventUserID `json:"sender_id,omitempty"` 7 | SenderType string `json:"sender_type,omitempty"` 8 | TenantKey string `json:"tenant_key,omitempty"` 9 | } `json:"sender,omitempty"` 10 | Message struct { 11 | MessageID string `json:"message_id,omitempty"` 12 | RootID string `json:"root_id,omitempty"` 13 | ParentID string `json:"parent_id,omitempty"` 14 | CreateTime string `json:"create_time,omitempty"` 15 | UpdateTime string `json:"update_time,omitempty"` 16 | ChatID string `json:"chat_id,omitempty"` 17 | ChatType string `json:"chat_type,omitempty"` 18 | ThreadID string `json:"thread_id,omitempty"` 19 | MessageType string `json:"message_type,omitempty"` 20 | Content string `json:"content,omitempty"` 21 | Mentions []struct { 22 | Key string `json:"key,omitempty"` 23 | ID EventUserID `json:"id,omitempty"` 24 | Name string `json:"name,omitempty"` 25 | TenantKey string `json:"tenant_key,omitempty"` 26 | } `json:"mentions,omitempty"` 27 | UserAgent string `json:"user_agent,omitempty"` 28 | } `json:"message,omitempty"` 29 | } 30 | 31 | // GetMessageReceived . 32 | func (e Event) GetMessageReceived() (*EventMessageReceived, error) { 33 | var body EventMessageReceived 34 | err := e.GetEvent(EventTypeMessageReceived, &body) 35 | return &body, err 36 | } 37 | -------------------------------------------------------------------------------- /v2/event_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestPostEvent(t *testing.T) { 14 | message := Event{ 15 | Schema: "2.0", 16 | Header: EventHeader{ 17 | AppID: "666", 18 | }, 19 | Event: EventBody{ 20 | Type: "message", 21 | ChatType: "group", 22 | MsgType: "text", 23 | OpenID: testUserOpenID, 24 | Text: "public event", 25 | Title: "", 26 | OpenMessageID: "", 27 | ImageKey: "", 28 | ImageURL: "", 29 | }, 30 | } 31 | w := performRequest(func(w http.ResponseWriter, r *http.Request) { 32 | var m Event 33 | json.NewDecoder(r.Body).Decode(&m) 34 | w.Write([]byte(m.Schema)) 35 | }, "POST", "/", message) 36 | assert.Equal(t, "2.0", string(w.Body.Bytes())) 37 | 38 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 39 | m, _ := json.Marshal(message) 40 | w.Write([]byte(m)) 41 | })) 42 | defer ts.Close() 43 | resp, err := message.PostEvent(http.DefaultClient, ts.URL) 44 | if assert.NoError(t, err) { 45 | var event Event 46 | body, err := ioutil.ReadAll(resp.Body) 47 | if assert.NoError(t, err) { 48 | defer resp.Body.Close() 49 | _ = json.Unmarshal(body, &event) 50 | assert.Equal(t, "2.0", event.Schema) 51 | assert.Equal(t, "666", event.Header.AppID) 52 | } 53 | } 54 | } 55 | 56 | func TestEventTypes(t *testing.T) { 57 | event := Event{ 58 | Header: EventHeader{ 59 | EventType: EventTypeChatDisbanded, 60 | }, 61 | EventRaw: json.RawMessage(`{ "message": { "chat_id": "oc_ae7f3952a9b28588aeac46c9853d25d3", "chat_type": "p2p", "content": "{\"text\":\"333\"}", "create_time": "1641385820771", "message_id": "om_6ff2cff41a3e9248bbb19bf0e4762e6e", "message_type": "text" }, "sender": { "sender_id": { "open_id": "ou_4f75b532aff410181e93552ad0532072", "union_id": "on_2312aab89ab7c87beb9a443b2f3b1342", "user_id": "4gbb63af" }, "sender_type": "user", "tenant_key": "736588c9260f175d" } }`), 62 | } 63 | m, e := event.GetMessageReceived() 64 | assert.Error(t, e) 65 | event.Header.EventType = EventTypeMessageReceived 66 | m, e = event.GetMessageReceived() 67 | assert.NoError(t, e) 68 | assert.Equal(t, "p2p", m.Message.ChatType) 69 | } 70 | 71 | func TestGetEvent(t *testing.T) { 72 | event := Event{ 73 | Header: EventHeader{ 74 | EventType: EventTypeMessageReceived, 75 | }, 76 | EventRaw: json.RawMessage(`{ "message": { "chat_id": "oc_ae7f3952a9b28588aeac46c9853d25d3", "chat_type": "p2p", "content": "{\"text\":\"333\"}", "create_time": "1641385820771", "message_id": "om_6ff2cff41a3e9248bbb19bf0e4762e6e", "message_type": "text" }, "sender": { "sender_id": { "open_id": "ou_4f75b532aff410181e93552ad0532072", "union_id": "on_2312aab89ab7c87beb9a443b2f3b1342", "user_id": "4gbb63af" }, "sender_type": "user", "tenant_key": "736588c9260f175d" } }`), 77 | } 78 | var ev EventMessageReceived 79 | err := event.GetEvent(EventTypeMessageReceived, &ev) 80 | if assert.NoError(t, err) { 81 | assert.Equal(t, "oc_ae7f3952a9b28588aeac46c9853d25d3", ev.Message.ChatID) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /v2/event_user_added.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // EventUserAdded . 4 | type EventUserAdded struct { 5 | ChatID string `json:"chat_id,omitempty"` 6 | OperatorID EventUserID `json:"operator_id,omitempty"` 7 | External bool `json:"external,omitempty"` 8 | OperatorTenantKey string `json:"operator_tenant_key,omitempty"` 9 | Users []struct { 10 | Name string `json:"name,omitempty"` 11 | TenantKey string `json:"tenant_key,omitempty"` 12 | UserID EventUserID `json:"user_id,omitempty"` 13 | } `json:"users,omitempty"` 14 | } 15 | 16 | // GetUserAdded . 17 | func (e Event) GetUserAdded() (*EventUserAdded, error) { 18 | var body EventUserAdded 19 | err := e.GetEvent(EventTypeUserAdded, &body) 20 | return &body, err 21 | } 22 | -------------------------------------------------------------------------------- /v2/event_user_deleted.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // EventUserDeleted . 4 | type EventUserDeleted = EventUserAdded 5 | 6 | // GetUserDeleted . 7 | func (e Event) GetUserDeleted() (*EventUserDeleted, error) { 8 | var body EventUserDeleted 9 | err := e.GetEvent(EventTypeUserDeleted, &body) 10 | return &body, err 11 | } 12 | -------------------------------------------------------------------------------- /v2/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/go-lark/lark/v2 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/go-lark/card-builder v1.0.0-beta.2 7 | github.com/joho/godotenv v1.5.1 8 | github.com/stretchr/testify v1.10.0 9 | ) 10 | 11 | require ( 12 | github.com/davecgh/go-spew v1.1.1 // indirect 13 | github.com/pmezard/go-difflib v1.0.0 // indirect 14 | gopkg.in/yaml.v3 v3.0.1 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /v2/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/go-lark/card-builder v1.0.0-beta.2 h1:DfH/14fs+R/G8CC+a2WoIp2wpWoD2isDcBpYZoSLwaU= 4 | github.com/go-lark/card-builder v1.0.0-beta.2/go.mod h1:7O2nuzjG4Cc/ty0s7Q7roYXOVxt9Gzrw0jBHsuP3UaM= 5 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 6 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 10 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 11 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 13 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 14 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 15 | -------------------------------------------------------------------------------- /v2/http.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/http/httputil" 11 | "time" 12 | ) 13 | 14 | // HTTPClient is an interface handling http requests 15 | type HTTPClient interface { 16 | Do(ctx context.Context, req *http.Request) (*http.Response, error) 17 | } 18 | 19 | // ExpandURL expands url path to full url 20 | func (bot Bot) ExpandURL(urlPath string) string { 21 | url := fmt.Sprintf("%s%s", bot.domain, urlPath) 22 | return url 23 | } 24 | 25 | func (bot Bot) httpErrorLog(ctx context.Context, prefix, text string, err error) { 26 | bot.logger.Log(ctx, LogLevelError, fmt.Sprintf("[%s] %s: %+v\n", prefix, text, err)) 27 | } 28 | 29 | func (bot *Bot) loadAndRenewToken(ctx context.Context) (string, error) { 30 | now := time.Now() 31 | // check token 32 | token, ok := bot.tenantAccessToken.Load().(TenantAccessToken) 33 | tenantAccessToken := token.TenantAccessToken 34 | if !ok || token.TenantAccessToken == "" || (token.EstimatedExpireAt != nil && now.After(*token.EstimatedExpireAt)) { 35 | // renew token 36 | if bot.autoRenew { 37 | tacResp, err := bot.GetTenantAccessTokenInternal(ctx) 38 | if err != nil { 39 | return "", err 40 | } 41 | now := time.Now() 42 | expire := time.Duration(tacResp.Expire - 10) 43 | eta := now.Add(expire) 44 | token := TenantAccessToken{ 45 | TenantAccessToken: tacResp.TenantAccessToken, 46 | Expire: expire, 47 | LastUpdatedAt: &now, 48 | EstimatedExpireAt: &eta, 49 | } 50 | bot.tenantAccessToken.Store(token) 51 | tenantAccessToken = tacResp.TenantAccessToken 52 | } 53 | } 54 | return tenantAccessToken, nil 55 | } 56 | 57 | // PerformAPIRequest performs API request 58 | func (bot Bot) PerformAPIRequest( 59 | ctx context.Context, 60 | method string, 61 | prefix, urlPath string, 62 | header http.Header, auth bool, 63 | body io.Reader, 64 | output interface{}, 65 | ) error { 66 | var ( 67 | err error 68 | respBody io.ReadCloser 69 | url = bot.ExpandURL(urlPath) 70 | ) 71 | if header == nil { 72 | header = make(http.Header) 73 | } 74 | if auth { 75 | tenantAccessToken, err := bot.loadAndRenewToken(ctx) 76 | if err != nil { 77 | return err 78 | } 79 | header.Add("Authorization", fmt.Sprintf("Bearer %s", tenantAccessToken)) 80 | } 81 | req, err := http.NewRequest(method, url, body) 82 | if err != nil { 83 | bot.httpErrorLog(ctx, prefix, "init request failed", err) 84 | return err 85 | } 86 | req.Header = header 87 | resp, err := bot.client.Do(ctx, req) 88 | if err != nil { 89 | bot.httpErrorLog(ctx, prefix, "call failed", err) 90 | return err 91 | } 92 | if bot.debug { 93 | b, _ := httputil.DumpResponse(resp, true) 94 | bot.logger.Log(ctx, LogLevelDebug, string(b)) 95 | } 96 | respBody = resp.Body 97 | defer respBody.Close() 98 | buffer, err := io.ReadAll(respBody) 99 | if err != nil { 100 | bot.httpErrorLog(ctx, prefix, "read body failed", err) 101 | return err 102 | } 103 | // read response content 104 | err = json.Unmarshal(buffer, &output) 105 | if err != nil { 106 | bot.httpErrorLog(ctx, prefix, "decode body failed", err) 107 | return err 108 | } 109 | // read error code 110 | var dummyOutput DummyResponse 111 | err = json.Unmarshal(buffer, &dummyOutput) 112 | if err == nil && dummyOutput.Code != 0 { 113 | apiError := APIError(url, dummyOutput.BaseResponse) 114 | bot.logger.Log(ctx, LogLevelError, apiError.Error()) 115 | return apiError 116 | } 117 | return err 118 | } 119 | 120 | func (bot Bot) wrapAPIRequest(ctx context.Context, method, prefix, urlPath string, auth bool, params interface{}, output interface{}) error { 121 | buf := new(bytes.Buffer) 122 | err := json.NewEncoder(buf).Encode(params) 123 | if err != nil { 124 | bot.httpErrorLog(ctx, prefix, "encode JSON failed", err) 125 | return err 126 | } 127 | 128 | header := make(http.Header) 129 | header.Set("Content-Type", "application/json; charset=utf-8") 130 | err = bot.PerformAPIRequest(ctx, method, prefix, urlPath, header, auth, buf, output) 131 | return err 132 | } 133 | 134 | // PostAPIRequest POSTs Lark API 135 | func (bot Bot) PostAPIRequest(ctx context.Context, prefix, urlPath string, auth bool, params interface{}, output interface{}) error { 136 | return bot.wrapAPIRequest(ctx, http.MethodPost, prefix, urlPath, auth, params, output) 137 | } 138 | 139 | // GetAPIRequest GETs Lark API 140 | func (bot Bot) GetAPIRequest(ctx context.Context, prefix, urlPath string, auth bool, params interface{}, output interface{}) error { 141 | return bot.wrapAPIRequest(ctx, http.MethodGet, prefix, urlPath, auth, params, output) 142 | } 143 | 144 | // DeleteAPIRequest DELETEs Lark API 145 | func (bot Bot) DeleteAPIRequest(ctx context.Context, prefix, urlPath string, auth bool, params interface{}, output interface{}) error { 146 | return bot.wrapAPIRequest(ctx, http.MethodDelete, prefix, urlPath, auth, params, output) 147 | } 148 | 149 | // PutAPIRequest PUTs Lark API 150 | func (bot Bot) PutAPIRequest(ctx context.Context, prefix, urlPath string, auth bool, params interface{}, output interface{}) error { 151 | return bot.wrapAPIRequest(ctx, http.MethodPut, prefix, urlPath, auth, params, output) 152 | } 153 | 154 | // PatchAPIRequest PATCHes Lark API 155 | func (bot Bot) PatchAPIRequest(ctx context.Context, prefix, urlPath string, auth bool, params interface{}, output interface{}) error { 156 | return bot.wrapAPIRequest(ctx, http.MethodPatch, prefix, urlPath, auth, params, output) 157 | } 158 | -------------------------------------------------------------------------------- /v2/http_client.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | // defaultClient . 10 | type defaultClient struct { 11 | c *http.Client 12 | } 13 | 14 | // newDefaultClient . 15 | func newDefaultClient() *defaultClient { 16 | return &defaultClient{ 17 | c: &http.Client{ 18 | Timeout: 5 * time.Second, 19 | }, 20 | } 21 | } 22 | 23 | // Do . 24 | func (dc defaultClient) Do(ctx context.Context, req *http.Request) (*http.Response, error) { 25 | req.WithContext(ctx) 26 | return dc.c.Do(req) 27 | } 28 | -------------------------------------------------------------------------------- /v2/http_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestExpandURL(t *testing.T) { 10 | bot := NewChatBot("test-id", "test-secret") 11 | bot.SetDomain("http://localhost") 12 | assert.Equal(t, bot.ExpandURL("/test"), 13 | "http://localhost/test") 14 | } 15 | 16 | func TestAPIError(t *testing.T) { 17 | resp, err := bot.PostText(t.Context(), "failing", WithChatID("1231")) 18 | t.Log(resp, err) 19 | assert.Error(t, err) 20 | assert.NotZero(t, resp.Code) 21 | } 22 | -------------------------------------------------------------------------------- /v2/lark.go: -------------------------------------------------------------------------------- 1 | // Package lark is an easy-to-use SDK for Feishu and Lark Open Platform, 2 | // which implements messaging APIs, with full-fledged supports on building Chat Bot and Notification Bot. 3 | package lark 4 | 5 | import ( 6 | "sync/atomic" 7 | "time" 8 | ) 9 | 10 | const ( 11 | // ChatBot should be created with NewChatBot 12 | // Create from https://open.feishu.cn/ or https://open.larksuite.com/ 13 | ChatBot = iota 14 | // NotificationBot for webhook, behave as a simpler notification bot 15 | // Create from Lark group 16 | NotificationBot 17 | ) 18 | 19 | // Bot definition 20 | type Bot struct { 21 | // bot type 22 | botType int 23 | // auth info 24 | appID string 25 | appSecret string 26 | // access token 27 | tenantAccessToken atomic.Value 28 | autoRenew bool 29 | userAccessToken atomic.Value 30 | // user id type for api chat 31 | userIDType string 32 | // webhook for NotificationBot 33 | webhook string 34 | // API Domain 35 | domain string 36 | // http client 37 | client HTTPClient 38 | // logger 39 | logger LogWrapper 40 | debug bool 41 | } 42 | 43 | // Domains 44 | const ( 45 | DomainFeishu = "https://open.feishu.cn" 46 | DomainLark = "https://open.larksuite.com" 47 | ) 48 | 49 | // TenantAccessToken . 50 | type TenantAccessToken struct { 51 | TenantAccessToken string 52 | Expire time.Duration 53 | LastUpdatedAt *time.Time 54 | EstimatedExpireAt *time.Time 55 | } 56 | 57 | // NewChatBot with appID and appSecret 58 | func NewChatBot(appID, appSecret string) *Bot { 59 | bot := &Bot{ 60 | botType: ChatBot, 61 | appID: appID, 62 | appSecret: appSecret, 63 | client: newDefaultClient(), 64 | domain: DomainFeishu, 65 | logger: initDefaultLogger(), 66 | } 67 | bot.autoRenew = true 68 | bot.tenantAccessToken.Store(TenantAccessToken{}) 69 | 70 | return bot 71 | } 72 | 73 | // NewNotificationBot with URL 74 | func NewNotificationBot(hookURL string) *Bot { 75 | bot := &Bot{ 76 | botType: NotificationBot, 77 | webhook: hookURL, 78 | client: newDefaultClient(), 79 | logger: initDefaultLogger(), 80 | } 81 | bot.tenantAccessToken.Store(TenantAccessToken{}) 82 | 83 | return bot 84 | } 85 | 86 | // requireType checks whether the action is allowed in a list of bot types 87 | func (bot Bot) requireType(botType ...int) bool { 88 | for _, iterType := range botType { 89 | if bot.botType == iterType { 90 | return true 91 | } 92 | } 93 | return false 94 | } 95 | 96 | // SetClient assigns a new client to bot.client 97 | func (bot *Bot) SetClient(c HTTPClient) { 98 | bot.client = c 99 | } 100 | 101 | // SetDomain sets domain of endpoint, so we could call Feishu/Lark 102 | // go-lark does not check your host, just use the right one or fail. 103 | func (bot *Bot) SetDomain(domain string) { 104 | bot.domain = domain 105 | } 106 | 107 | // Domain returns current domain 108 | func (bot Bot) Domain() string { 109 | return bot.domain 110 | } 111 | 112 | // AppID returns bot.appID for external use 113 | func (bot Bot) AppID() string { 114 | return bot.appID 115 | } 116 | 117 | // BotType returns bot.botType for external use 118 | func (bot Bot) BotType() int { 119 | return bot.botType 120 | } 121 | 122 | // TenantAccessToken returns tenant access token for external use 123 | func (bot Bot) TenantAccessToken() string { 124 | token := bot.tenantAccessToken.Load().(TenantAccessToken) 125 | return token.TenantAccessToken 126 | } 127 | 128 | // SetTenantAccessToken sets tenant access token 129 | func (bot *Bot) SetTenantAccessToken(t TenantAccessToken) { 130 | bot.tenantAccessToken.Store(t) 131 | } 132 | 133 | // SetAutoRenew sets autoRenew 134 | func (bot *Bot) SetAutoRenew(onOff bool) { 135 | bot.autoRenew = onOff 136 | } 137 | 138 | // SetWebhook sets webhook URL 139 | func (bot *Bot) SetWebhook(url string) { 140 | bot.webhook = url 141 | } 142 | -------------------------------------------------------------------------------- /v2/lark_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "log" 7 | "net/http" 8 | "net/http/httptest" 9 | "os" 10 | "testing" 11 | 12 | "github.com/joho/godotenv" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | // IDs for test use 17 | var ( 18 | testAppID string 19 | testAppSecret string 20 | testUserEmail string 21 | testUserOpenID string 22 | testUserID string 23 | testUserUnionID string 24 | testGroupChatID string 25 | testWebhookV1 string 26 | testWebhook string 27 | testWebhookSigned string 28 | ) 29 | 30 | func newTestBot() *Bot { 31 | testMode := os.Getenv("GO_LARK_TEST_MODE") 32 | if testMode == "" { 33 | testMode = "testing" 34 | } 35 | if testMode == "local" { 36 | err := godotenv.Load(".env") 37 | if err != nil { 38 | panic(err) 39 | } 40 | } 41 | testAppID = os.Getenv("LARK_APP_ID") 42 | testAppSecret = os.Getenv("LARK_APP_SECRET") 43 | testUserEmail = os.Getenv("LARK_USER_EMAIL") 44 | testUserID = os.Getenv("LARK_USER_ID") 45 | testUserUnionID = os.Getenv("LARK_UNION_ID") 46 | testUserOpenID = os.Getenv("LARK_OPEN_ID") 47 | testGroupChatID = os.Getenv("LARK_CHAT_ID") 48 | testWebhook = os.Getenv("LARK_WEBHOOK") 49 | testWebhookSigned = os.Getenv("LARK_WEBHOOK_SIGNED") 50 | if len(testAppID) == 0 || 51 | len(testAppSecret) == 0 || 52 | len(testUserEmail) == 0 || 53 | len(testUserID) == 0 || 54 | len(testUserUnionID) == 0 || 55 | len(testUserOpenID) == 0 || 56 | len(testGroupChatID) == 0 { 57 | panic("insufficient test environment") 58 | } 59 | return NewChatBot(testAppID, testAppSecret) 60 | } 61 | 62 | func captureOutput(f func()) string { 63 | var buf bytes.Buffer 64 | log.SetOutput(&buf) 65 | f() 66 | log.SetOutput(os.Stderr) 67 | return buf.String() 68 | } 69 | 70 | func performRequest(r http.HandlerFunc, method, path string, body interface{}) *httptest.ResponseRecorder { 71 | buf := new(bytes.Buffer) 72 | json.NewEncoder(buf).Encode(body) 73 | req := httptest.NewRequest(method, path, buf) 74 | w := httptest.NewRecorder() 75 | r.ServeHTTP(w, req) 76 | return w 77 | } 78 | 79 | // for general API test suites 80 | var bot *Bot 81 | 82 | func init() { 83 | bot = newTestBot() 84 | } 85 | 86 | func TestBotProperties(t *testing.T) { 87 | chatBot := newTestBot() 88 | assert.NotEmpty(t, chatBot.appID) 89 | assert.NotEmpty(t, chatBot.appSecret) 90 | assert.Empty(t, chatBot.webhook) 91 | assert.Equal(t, DomainFeishu, chatBot.domain) 92 | assert.Equal(t, ChatBot, chatBot.botType) 93 | assert.NotNil(t, chatBot.client) 94 | assert.NotNil(t, chatBot.logger) 95 | 96 | notifyBot := NewNotificationBot(testWebhook) 97 | assert.Empty(t, notifyBot.appID) 98 | assert.Empty(t, notifyBot.appSecret) 99 | assert.NotEmpty(t, notifyBot.webhook) 100 | assert.Empty(t, notifyBot.domain) 101 | assert.Equal(t, NotificationBot, notifyBot.botType) 102 | assert.NotNil(t, notifyBot.client) 103 | assert.NotNil(t, notifyBot.logger) 104 | } 105 | 106 | func TestRequiredType(t *testing.T) { 107 | bot := newTestBot() 108 | assert.True(t, bot.requireType(ChatBot)) 109 | assert.False(t, bot.requireType(NotificationBot)) 110 | } 111 | 112 | func TestSetDomain(t *testing.T) { 113 | bot := newTestBot() 114 | assert.Equal(t, DomainFeishu, bot.domain) 115 | assert.Equal(t, DomainFeishu, bot.Domain()) 116 | bot.SetDomain("https://test.test") 117 | assert.Equal(t, "https://test.test", bot.domain) 118 | assert.Equal(t, "https://test.test", bot.Domain()) 119 | } 120 | 121 | func TestBotGetters(t *testing.T) { 122 | bot := newTestBot() 123 | assert.Equal(t, testAppID, bot.AppID()) 124 | assert.Equal(t, ChatBot, bot.BotType()) 125 | } 126 | 127 | func TestSetClient(t *testing.T) { 128 | bot := &Bot{} 129 | assert.Nil(t, bot.client) 130 | bot.SetClient(newDefaultClient()) 131 | assert.NotNil(t, bot.client) 132 | } 133 | 134 | func TestSetWebhook(t *testing.T) { 135 | bot := NewNotificationBot("abc") 136 | assert.Equal(t, "abc", bot.webhook) 137 | bot.SetWebhook("def") 138 | assert.Equal(t, "def", bot.webhook) 139 | } 140 | 141 | func TestSetAutoRenew(t *testing.T) { 142 | bot := newTestBot() 143 | assert.True(t, bot.autoRenew) 144 | bot.SetAutoRenew(false) 145 | assert.False(t, bot.autoRenew) 146 | bot.SetAutoRenew(true) 147 | assert.True(t, bot.autoRenew) 148 | } 149 | 150 | func TestTenantAccessToken(t *testing.T) { 151 | bot := newTestBot() 152 | assert.Equal(t, "", bot.TenantAccessToken()) 153 | bot.SetTenantAccessToken(TenantAccessToken{ 154 | TenantAccessToken: "test", 155 | }) 156 | assert.Equal(t, "test", bot.TenantAccessToken()) 157 | } 158 | -------------------------------------------------------------------------------- /v2/locale.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // Supported Lark locales 4 | const ( 5 | LocaleZhCN = "zh_cn" 6 | LocaleZhHK = "zh_hk" 7 | LocaleZhTW = "zh_tw" 8 | LocaleEnUS = "en_us" 9 | LocaleJaJP = "ja_jp" 10 | ) 11 | -------------------------------------------------------------------------------- /v2/logger.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "log" 7 | "os" 8 | ) 9 | 10 | // LogLevel defs 11 | type LogLevel int 12 | 13 | // LogLevels 14 | const ( 15 | LogLevelTrace = iota + 1 16 | LogLevelDebug 17 | LogLevelInfo 18 | LogLevelWarn 19 | LogLevelError 20 | ) 21 | 22 | // LogWrapper interface 23 | type LogWrapper interface { 24 | // for log print 25 | Log(context.Context, LogLevel, string) 26 | // for test redirection 27 | SetOutput(io.Writer) 28 | } 29 | 30 | // String . 31 | func (ll LogLevel) String() string { 32 | switch ll { 33 | case LogLevelTrace: 34 | return "TRACE" 35 | case LogLevelDebug: 36 | return "DEBUG" 37 | case LogLevelInfo: 38 | return "INFO" 39 | case LogLevelWarn: 40 | return "WARN" 41 | case LogLevelError: 42 | return "ERROR" 43 | } 44 | return "" 45 | } 46 | 47 | type stdLogger struct { 48 | *log.Logger 49 | } 50 | 51 | // Log standard log func 52 | func (sl stdLogger) Log(_ context.Context, level LogLevel, msg string) { 53 | sl.Printf("[%s] %s\n", level, msg) 54 | } 55 | 56 | const logPrefix = "[go-lark] " 57 | 58 | func initDefaultLogger() LogWrapper { 59 | // create a default std logger 60 | logger := stdLogger{ 61 | log.New(os.Stderr, logPrefix, log.LstdFlags), 62 | } 63 | return logger 64 | } 65 | 66 | // SetLogger sets a new logger 67 | func (bot *Bot) SetLogger(logger LogWrapper) { 68 | bot.logger = logger 69 | } 70 | 71 | // Logger returns current logger 72 | func (bot Bot) Logger() LogWrapper { 73 | return bot.logger 74 | } 75 | -------------------------------------------------------------------------------- /v2/logger_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestSetLogger(t *testing.T) { 10 | bot := newTestBot() 11 | newLogger := initDefaultLogger() 12 | bot.SetLogger(newLogger) 13 | assert.Equal(t, newLogger, bot.logger) 14 | assert.Equal(t, newLogger, bot.Logger()) 15 | } 16 | 17 | func TestLogLevel(t *testing.T) { 18 | var logLevel LogLevel = LogLevelDebug 19 | assert.Equal(t, "DEBUG", logLevel.String()) 20 | logLevel = LogLevelError 21 | assert.Equal(t, "ERROR", logLevel.String()) 22 | logLevel = LogLevelTrace 23 | assert.Equal(t, "TRACE", logLevel.String()) 24 | logLevel = LogLevelWarn 25 | assert.Equal(t, "WARN", logLevel.String()) 26 | logLevel = LogLevelInfo 27 | assert.Equal(t, "INFO", logLevel.String()) 28 | logLevel = 1000 29 | assert.Equal(t, "", logLevel.String()) 30 | } 31 | -------------------------------------------------------------------------------- /v2/message.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // Msg Types 4 | const ( 5 | MsgText = "text" 6 | MsgPost = "post" 7 | MsgInteractive = "interactive" 8 | MsgImage = "image" 9 | MsgShareCard = "share_chat" 10 | MsgShareUser = "share_user" 11 | MsgAudio = "audio" 12 | MsgMedia = "media" 13 | MsgFile = "file" 14 | MsgSticker = "sticker" 15 | ) 16 | 17 | // OutcomingMessage struct of an outcoming message 18 | type OutcomingMessage struct { 19 | MsgType string `json:"msg_type"` 20 | Content MessageContent `json:"content"` 21 | Card CardContent `json:"card"` 22 | // ID for user 23 | UIDType string `json:"-"` 24 | OpenID string `json:"open_id,omitempty"` 25 | Email string `json:"email,omitempty"` 26 | UserID string `json:"user_id,omitempty"` 27 | ChatID string `json:"chat_id,omitempty"` 28 | UnionID string `json:"-"` 29 | // For reply 30 | RootID string `json:"root_id,omitempty"` 31 | ReplyInThread bool `json:"reply_in_thread,omitempty"` 32 | // Sign for notification bot 33 | Sign string `json:"sign"` 34 | // Timestamp for sign 35 | Timestamp int64 `json:"timestamp"` 36 | // UUID for idempotency 37 | UUID string `json:"uuid"` 38 | } 39 | 40 | // CardContent struct of card content 41 | type CardContent map[string]interface{} 42 | 43 | // MessageContent struct of message content 44 | type MessageContent struct { 45 | Text *TextContent `json:"text,omitempty"` 46 | Image *ImageContent `json:"image,omitempty"` 47 | Post *PostContent `json:"post,omitempty"` 48 | Card *CardContent `json:"card,omitempty"` 49 | ShareChat *ShareChatContent `json:"share_chat,omitempty"` 50 | ShareUser *ShareUserContent `json:"share_user,omitempty"` 51 | Audio *AudioContent `json:"audio,omitempty"` 52 | Media *MediaContent `json:"media,omitempty"` 53 | File *FileContent `json:"file,omitempty"` 54 | Sticker *StickerContent `json:"sticker,omitempty"` 55 | Template *TemplateContent `json:"template,omitempty"` 56 | } 57 | 58 | // TextContent . 59 | type TextContent struct { 60 | Text string `json:"text"` 61 | } 62 | 63 | // ImageContent . 64 | type ImageContent struct { 65 | ImageKey string `json:"image_key"` 66 | } 67 | 68 | // ShareChatContent . 69 | type ShareChatContent struct { 70 | ChatID string `json:"chat_id"` 71 | } 72 | 73 | // ShareUserContent . 74 | type ShareUserContent struct { 75 | UserID string `json:"user_id"` 76 | } 77 | 78 | // AudioContent . 79 | type AudioContent struct { 80 | FileKey string `json:"file_key"` 81 | } 82 | 83 | // MediaContent . 84 | type MediaContent struct { 85 | FileName string `json:"file_name,omitempty"` 86 | FileKey string `json:"file_key"` 87 | ImageKey string `json:"image_key"` 88 | Duration int `json:"duration,omitempty"` 89 | } 90 | 91 | // FileContent . 92 | type FileContent struct { 93 | FileName string `json:"file_name,omitempty"` 94 | FileKey string `json:"file_key"` 95 | } 96 | 97 | // StickerContent . 98 | type StickerContent struct { 99 | FileKey string `json:"file_key"` 100 | } 101 | 102 | // TemplateContent . 103 | type TemplateContent struct { 104 | Type string `json:"type"` 105 | Data templateData `json:"data,omitempty"` 106 | } 107 | 108 | type templateData struct { 109 | TemplateID string `json:"template_id"` 110 | TemplateVersionName string `json:"template_version_name,omitempty"` 111 | TemplateVariable map[string]interface{} `json:"template_variable,omitempty"` 112 | } 113 | -------------------------------------------------------------------------------- /v2/message_builder.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "encoding/json" 5 | "strconv" 6 | ) 7 | 8 | // BuildMessage . 9 | func BuildMessage(om OutcomingMessage) (*IMMessageRequest, error) { 10 | req := IMMessageRequest{ 11 | MsgType: string(om.MsgType), 12 | Content: buildContent(om), 13 | ReceiveID: buildReceiveID(om), 14 | } 15 | if req.ReceiveID == "" { 16 | return nil, ErrInvalidReceiveID 17 | } 18 | if req.Content == "" { 19 | return nil, ErrMessageNotBuild 20 | } 21 | if om.UUID != "" { 22 | req.UUID = om.UUID 23 | } 24 | return &req, nil 25 | } 26 | 27 | func buildReplyMessage(om OutcomingMessage) (*IMMessageRequest, error) { 28 | req := IMMessageRequest{ 29 | MsgType: string(om.MsgType), 30 | Content: buildContent(om), 31 | ReceiveID: buildReceiveID(om), 32 | } 33 | if req.Content == "" { 34 | return nil, ErrMessageNotBuild 35 | } 36 | if om.ReplyInThread { 37 | req.ReplyInThread = om.ReplyInThread 38 | } 39 | if om.UUID != "" { 40 | req.UUID = om.UUID 41 | } 42 | 43 | return &req, nil 44 | } 45 | 46 | func buildUpdateMessage(om OutcomingMessage) (*IMMessageRequest, error) { 47 | req := IMMessageRequest{ 48 | Content: buildContent(om), 49 | } 50 | if om.MsgType != MsgInteractive { 51 | req.MsgType = om.MsgType 52 | } 53 | if req.Content == "" { 54 | return nil, ErrMessageNotBuild 55 | } 56 | 57 | return &req, nil 58 | } 59 | 60 | func buildContent(om OutcomingMessage) string { 61 | var ( 62 | content = "" 63 | b []byte 64 | err error 65 | ) 66 | switch om.MsgType { 67 | case MsgText: 68 | b, err = json.Marshal(om.Content.Text) 69 | case MsgImage: 70 | b, err = json.Marshal(om.Content.Image) 71 | case MsgFile: 72 | b, err = json.Marshal(om.Content.File) 73 | case MsgShareCard: 74 | b, err = json.Marshal(om.Content.ShareChat) 75 | case MsgShareUser: 76 | b, err = json.Marshal(om.Content.ShareUser) 77 | case MsgPost: 78 | b, err = json.Marshal(om.Content.Post) 79 | case MsgInteractive: 80 | if om.Content.Card != nil { 81 | b, err = json.Marshal(om.Content.Card) 82 | } else if om.Content.Template != nil { 83 | b, err = json.Marshal(om.Content.Template) 84 | } 85 | case MsgAudio: 86 | b, err = json.Marshal(om.Content.Audio) 87 | case MsgMedia: 88 | b, err = json.Marshal(om.Content.Media) 89 | case MsgSticker: 90 | b, err = json.Marshal(om.Content.Sticker) 91 | } 92 | if err != nil { 93 | return "" 94 | } 95 | content = string(b) 96 | 97 | return content 98 | } 99 | 100 | func buildReceiveID(om OutcomingMessage) string { 101 | switch om.UIDType { 102 | case UIDEmail: 103 | return om.Email 104 | case UIDUserID: 105 | return om.UserID 106 | case UIDOpenID: 107 | return om.OpenID 108 | case UIDChatID: 109 | return om.ChatID 110 | case UIDUnionID: 111 | return om.UnionID 112 | } 113 | return "" 114 | } 115 | 116 | func buildEphemeralCard(om OutcomingMessage) map[string]interface{} { 117 | params := map[string]interface{}{ 118 | "msg_type": om.MsgType, 119 | "chat_id": om.ChatID, // request must contain chat_id, even if it is empty 120 | } 121 | params[om.UIDType] = buildReceiveID(om) 122 | if len(om.RootID) > 0 { 123 | params["root_id"] = om.RootID 124 | } 125 | content := make(map[string]interface{}) 126 | if om.Content.Text != nil { 127 | content["text"] = om.Content.Text.Text 128 | } 129 | if om.Content.Image != nil { 130 | content["image_key"] = om.Content.Image.ImageKey 131 | } 132 | if om.Content.ShareChat != nil { 133 | content["share_open_chat_id"] = om.Content.ShareChat.ChatID 134 | } 135 | if om.Content.Post != nil { 136 | content["post"] = *om.Content.Post 137 | } 138 | if om.MsgType == MsgInteractive && om.Content.Card != nil { 139 | params["card"] = *om.Content.Card 140 | } 141 | if len(om.Sign) > 0 { 142 | params["sign"] = om.Sign 143 | params["timestamp"] = strconv.FormatInt(om.Timestamp, 10) 144 | } 145 | params["content"] = content 146 | return params 147 | } 148 | 149 | func buildNotification(om OutcomingMessage) map[string]interface{} { 150 | params := map[string]interface{}{ 151 | "msg_type": om.MsgType, 152 | } 153 | if len(om.RootID) > 0 { 154 | params["root_id"] = om.RootID 155 | } 156 | content := make(map[string]interface{}) 157 | if om.Content.Text != nil { 158 | content["text"] = om.Content.Text.Text 159 | } 160 | if om.Content.Image != nil { 161 | content["image_key"] = om.Content.Image.ImageKey 162 | } 163 | if om.Content.ShareChat != nil { 164 | content["share_open_chat_id"] = om.Content.ShareChat.ChatID 165 | } 166 | if om.Content.Post != nil { 167 | content["post"] = *om.Content.Post 168 | } 169 | if om.MsgType == MsgInteractive && om.Content.Card != nil { 170 | params["card"] = *om.Content.Card 171 | } 172 | if len(om.Sign) > 0 { 173 | params["sign"] = om.Sign 174 | params["timestamp"] = strconv.FormatInt(om.Timestamp, 10) 175 | } 176 | params["content"] = content 177 | return params 178 | } 179 | -------------------------------------------------------------------------------- /v2/msg_buf.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // MsgBuffer stores all the messages attached 9 | // You can call every function, but some of which is only available for specific condition 10 | type MsgBuffer struct { 11 | // Message type 12 | msgType string 13 | // Output 14 | message OutcomingMessage 15 | 16 | err error 17 | } 18 | 19 | // NewMsgBuffer creates a message buffer 20 | func NewMsgBuffer(newMsgType string) *MsgBuffer { 21 | msgBuffer := MsgBuffer{ 22 | message: OutcomingMessage{ 23 | MsgType: newMsgType, 24 | }, 25 | msgType: newMsgType, 26 | } 27 | return &msgBuffer 28 | } 29 | 30 | // BindOpenID binds open_id 31 | func (m *MsgBuffer) BindOpenID(openID string) *MsgBuffer { 32 | m.message.OpenID = openID 33 | m.message.UIDType = UIDOpenID 34 | return m 35 | } 36 | 37 | // BindEmail binds email 38 | func (m *MsgBuffer) BindEmail(email string) *MsgBuffer { 39 | m.message.Email = email 40 | m.message.UIDType = UIDEmail 41 | return m 42 | } 43 | 44 | // BindChatID binds chat_id 45 | func (m *MsgBuffer) BindChatID(chatID string) *MsgBuffer { 46 | m.message.ChatID = chatID 47 | m.message.UIDType = UIDChatID 48 | return m 49 | } 50 | 51 | // BindOpenChatID binds open_chat_id 52 | func (m *MsgBuffer) BindOpenChatID(openChatID string) *MsgBuffer { 53 | m.BindChatID(openChatID) 54 | m.message.UIDType = UIDChatID 55 | return m 56 | } 57 | 58 | // BindUserID binds open_id 59 | func (m *MsgBuffer) BindUserID(userID string) *MsgBuffer { 60 | m.message.UserID = userID 61 | m.message.UIDType = UIDUserID 62 | return m 63 | } 64 | 65 | // BindUnionID binds union_id 66 | func (m *MsgBuffer) BindUnionID(unionID string) *MsgBuffer { 67 | m.message.UnionID = unionID 68 | m.message.UIDType = UIDUnionID 69 | return m 70 | } 71 | 72 | // BindReply binds root id for reply 73 | // rootID is OpenMessageID of the message you reply 74 | func (m *MsgBuffer) BindReply(rootID string) *MsgBuffer { 75 | m.message.RootID = rootID 76 | return m 77 | } 78 | 79 | // ReplyInThread replies message in thread 80 | func (m *MsgBuffer) ReplyInThread(replyInThread bool) *MsgBuffer { 81 | m.message.ReplyInThread = replyInThread 82 | return m 83 | } 84 | 85 | // WithSign generates sign for notification bot check 86 | func (m *MsgBuffer) WithSign(secret string, ts int64) *MsgBuffer { 87 | m.message.Sign, _ = GenSign(secret, ts) 88 | m.message.Timestamp = ts 89 | return m 90 | } 91 | 92 | // WithUUID add UUID to message for idempotency 93 | func (m *MsgBuffer) WithUUID(uuid string) *MsgBuffer { 94 | m.message.UUID = uuid 95 | return m 96 | } 97 | 98 | func (m MsgBuffer) typeError(funcName string, msgType string) error { 99 | return fmt.Errorf("`%s` is only available to `%s`", funcName, msgType) 100 | } 101 | 102 | // Text attaches text 103 | func (m *MsgBuffer) Text(text string) *MsgBuffer { 104 | if m.msgType != MsgText { 105 | m.err = m.typeError("Text", MsgText) 106 | return m 107 | } 108 | m.message.Content.Text = &TextContent{ 109 | Text: text, 110 | } 111 | return m 112 | } 113 | 114 | // Image attaches image key 115 | // for MsgImage only 116 | func (m *MsgBuffer) Image(imageKey string) *MsgBuffer { 117 | if m.msgType != MsgImage { 118 | m.err = m.typeError("Image", MsgImage) 119 | return m 120 | } 121 | m.message.Content.Image = &ImageContent{ 122 | ImageKey: imageKey, 123 | } 124 | return m 125 | } 126 | 127 | // ShareChat attaches chat id 128 | // for MsgShareChat only 129 | func (m *MsgBuffer) ShareChat(chatID string) *MsgBuffer { 130 | if m.msgType != MsgShareCard { 131 | m.err = m.typeError("ShareChat", MsgShareCard) 132 | return m 133 | } 134 | m.message.Content.ShareChat = &ShareChatContent{ 135 | ChatID: chatID, 136 | } 137 | return m 138 | } 139 | 140 | // ShareUser attaches user id 141 | // for MsgShareUser only 142 | func (m *MsgBuffer) ShareUser(userID string) *MsgBuffer { 143 | if m.msgType != MsgShareUser { 144 | m.err = m.typeError("ShareUser", MsgShareUser) 145 | return m 146 | } 147 | m.message.Content.ShareUser = &ShareUserContent{ 148 | UserID: userID, 149 | } 150 | return m 151 | } 152 | 153 | // File attaches file 154 | // for MsgFile only 155 | func (m *MsgBuffer) File(fileKey string) *MsgBuffer { 156 | if m.msgType != MsgFile { 157 | m.err = m.typeError("File", MsgFile) 158 | return m 159 | } 160 | m.message.Content.File = &FileContent{ 161 | FileKey: fileKey, 162 | } 163 | return m 164 | } 165 | 166 | // Audio attaches audio 167 | // for MsgAudio only 168 | func (m *MsgBuffer) Audio(fileKey string) *MsgBuffer { 169 | if m.msgType != MsgAudio { 170 | m.err = m.typeError("Audio", MsgAudio) 171 | return m 172 | } 173 | m.message.Content.Audio = &AudioContent{ 174 | FileKey: fileKey, 175 | } 176 | return m 177 | } 178 | 179 | // Media attaches media 180 | // for MsgMedia only 181 | func (m *MsgBuffer) Media(fileKey, imageKey string) *MsgBuffer { 182 | if m.msgType != MsgMedia { 183 | m.err = m.typeError("Media", MsgMedia) 184 | return m 185 | } 186 | m.message.Content.Media = &MediaContent{ 187 | FileKey: fileKey, 188 | ImageKey: imageKey, 189 | } 190 | return m 191 | } 192 | 193 | // Sticker attaches sticker 194 | // for MsgSticker only 195 | func (m *MsgBuffer) Sticker(fileKey string) *MsgBuffer { 196 | if m.msgType != MsgSticker { 197 | m.err = m.typeError("Sticker", MsgSticker) 198 | return m 199 | } 200 | m.message.Content.Sticker = &StickerContent{ 201 | FileKey: fileKey, 202 | } 203 | return m 204 | } 205 | 206 | // Post sets raw post content 207 | func (m *MsgBuffer) Post(postContent *PostContent) *MsgBuffer { 208 | if m.msgType != MsgPost { 209 | m.err = m.typeError("Post", MsgPost) 210 | return m 211 | } 212 | m.message.Content.Post = postContent 213 | return m 214 | } 215 | 216 | // Card binds card content with V4 format 217 | func (m *MsgBuffer) Card(cardContent string) *MsgBuffer { 218 | if m.msgType != MsgInteractive { 219 | m.err = m.typeError("Card", MsgInteractive) 220 | return m 221 | } 222 | card := make(CardContent) 223 | _ = json.Unmarshal([]byte(cardContent), &card) 224 | m.message.Content.Card = &card 225 | return m 226 | } 227 | 228 | // Template sets raw template content 229 | func (m *MsgBuffer) Template(tempateContent *TemplateContent) *MsgBuffer { 230 | if m.msgType != MsgInteractive { 231 | m.err = m.typeError("Template", MsgInteractive) 232 | return m 233 | } 234 | m.message.Content.Template = tempateContent 235 | return m 236 | } 237 | 238 | // Build message and return message body 239 | func (m *MsgBuffer) Build() OutcomingMessage { 240 | return m.message 241 | } 242 | 243 | // Error returns last error 244 | func (m *MsgBuffer) Error() error { 245 | return m.err 246 | } 247 | 248 | // Clear message in buffer 249 | func (m *MsgBuffer) Clear() *MsgBuffer { 250 | m.message = OutcomingMessage{ 251 | MsgType: m.msgType, 252 | } 253 | m.err = nil 254 | return m 255 | } 256 | -------------------------------------------------------------------------------- /v2/msg_buf_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestAttachText(t *testing.T) { 10 | mb := NewMsgBuffer(MsgText) 11 | msg := mb.Text("hello").Build() 12 | assert.Equal(t, "hello", msg.Content.Text.Text) 13 | } 14 | 15 | func TestAttachImage(t *testing.T) { 16 | mb := NewMsgBuffer(MsgImage) 17 | msg := mb.Image("aaaaa").Build() 18 | assert.Equal(t, "aaaaa", msg.Content.Image.ImageKey) 19 | } 20 | 21 | func TestMsgTextBinding(t *testing.T) { 22 | mb := NewMsgBuffer(MsgText) 23 | msg := mb.Text("hello, world").BindEmail(testUserEmail).Build() 24 | assert.Equal(t, "hello, world", msg.Content.Text.Text) 25 | assert.Equal(t, testUserEmail, msg.Email) 26 | } 27 | 28 | func TestBindingUserIDs(t *testing.T) { 29 | mb := NewMsgBuffer(MsgText) 30 | msgEmail := mb.BindEmail(testUserEmail).Build() 31 | assert.Equal(t, testUserEmail, msgEmail.Email) 32 | 33 | mb.Clear() 34 | msgOpenChatID := mb.BindOpenChatID(testGroupChatID).Build() 35 | assert.Equal(t, testGroupChatID, msgOpenChatID.ChatID) 36 | 37 | mb.Clear() 38 | msgUserID := mb.BindUserID("333444").Build() 39 | assert.Equal(t, "333444", msgUserID.UserID) 40 | 41 | mb.Clear() 42 | msgReplyID := mb.BindReply("om_f779ffe0ffa3d1b94fc1ef5fcb6f1063").Build() 43 | assert.Equal(t, "om_f779ffe0ffa3d1b94fc1ef5fcb6f1063", msgReplyID.RootID) 44 | mb.ReplyInThread(true) 45 | assert.False(t, msgReplyID.ReplyInThread) 46 | } 47 | 48 | func TestMsgShareChat(t *testing.T) { 49 | mb := NewMsgBuffer(MsgShareCard) 50 | msg := mb.ShareChat("6559399282837815565").Build() 51 | assert.Equal(t, MsgShareCard, msg.MsgType) 52 | assert.Equal(t, "6559399282837815565", msg.Content.ShareChat.ChatID) 53 | } 54 | 55 | func TestMsgShareUser(t *testing.T) { 56 | mb := NewMsgBuffer(MsgShareUser) 57 | msg := mb.ShareUser("334455").Build() 58 | assert.Equal(t, MsgShareUser, msg.MsgType) 59 | assert.Equal(t, "334455", msg.Content.ShareUser.UserID) 60 | } 61 | 62 | func TestMsgFile(t *testing.T) { 63 | mb := NewMsgBuffer(MsgFile) 64 | msg := mb.File("file_v2_71cafb2c-137f-4bb0-8381-ffd4971dbecg").Build() 65 | assert.Equal(t, MsgFile, msg.MsgType) 66 | assert.Equal(t, "file_v2_71cafb2c-137f-4bb0-8381-ffd4971dbecg", msg.Content.File.FileKey) 67 | } 68 | 69 | func TestMsgAudio(t *testing.T) { 70 | mb := NewMsgBuffer(MsgAudio) 71 | msg := mb.Audio("file_v2_71cafb2c-137f-4bb0-8381-ffd4971dbecg").Build() 72 | assert.Equal(t, MsgAudio, msg.MsgType) 73 | assert.Equal(t, "file_v2_71cafb2c-137f-4bb0-8381-ffd4971dbecg", msg.Content.Audio.FileKey) 74 | } 75 | 76 | func TestMsgMedia(t *testing.T) { 77 | mb := NewMsgBuffer(MsgMedia) 78 | msg := mb.Media("file_v2_b53cd6cc-5327-4968-8bf6-4528deb3068g", "img_v2_b276195a-9ae0-4fec-bbfe-f74b4d5a994g").Build() 79 | assert.Equal(t, MsgMedia, msg.MsgType) 80 | assert.Equal(t, "file_v2_b53cd6cc-5327-4968-8bf6-4528deb3068g", msg.Content.Media.FileKey) 81 | assert.Equal(t, "img_v2_b276195a-9ae0-4fec-bbfe-f74b4d5a994g", msg.Content.Media.ImageKey) 82 | } 83 | 84 | func TestMsgSticker(t *testing.T) { 85 | mb := NewMsgBuffer(MsgSticker) 86 | msg := mb.Sticker("4ba009df-2453-47b3-a753-444b152217bg").Build() 87 | assert.Equal(t, MsgSticker, msg.MsgType) 88 | assert.Equal(t, "4ba009df-2453-47b3-a753-444b152217bg", msg.Content.Sticker.FileKey) 89 | } 90 | 91 | func TestMsgWithWrongType(t *testing.T) { 92 | mb := NewMsgBuffer(MsgText) 93 | mb.ShareChat("6559399282837815565") 94 | assert.Equal(t, mb.Error().Error(), "`ShareChat` is only available to `share_chat`") 95 | mb.ShareUser("334455") 96 | assert.Equal(t, mb.Error().Error(), "`ShareUser` is only available to `share_user`") 97 | mb.Image("aaa") 98 | assert.Equal(t, mb.Error().Error(), "`Image` is only available to `image`") 99 | mb.File("aaa") 100 | assert.Equal(t, mb.Error().Error(), "`File` is only available to `file`") 101 | mb.Audio("aaa") 102 | assert.Equal(t, mb.Error().Error(), "`Audio` is only available to `audio`") 103 | mb.Media("aaa", "bbb") 104 | assert.Equal(t, mb.Error().Error(), "`Media` is only available to `media`") 105 | mb.Sticker("aaa") 106 | assert.Equal(t, mb.Error().Error(), "`Sticker` is only available to `sticker`") 107 | mb.Post(nil) 108 | assert.Equal(t, mb.Error().Error(), "`Post` is only available to `post`") 109 | mb.Card("nil") 110 | assert.Equal(t, mb.Error().Error(), "`Card` is only available to `interactive`") 111 | mbp := NewMsgBuffer(MsgPost) 112 | mbp.Text("hello") 113 | assert.Equal(t, mbp.Error().Error(), "`Text` is only available to `text`") 114 | } 115 | 116 | func TestClearMessage(t *testing.T) { 117 | mb := NewMsgBuffer(MsgText) 118 | mb.Text("hello, world").Build() 119 | assert.Equal(t, "hello, world", mb.message.Content.Text.Text) 120 | mb.Clear() 121 | assert.Equal(t, MsgText, mb.msgType) 122 | assert.Empty(t, mb.message.Content) 123 | mb.Text("attach again").Build() 124 | assert.Equal(t, "attach again", mb.message.Content.Text.Text) 125 | } 126 | 127 | func TestWorkWithTextBuilder(t *testing.T) { 128 | mb := NewMsgBuffer(MsgText) 129 | mb.Text(NewTextBuilder().Textln("hello, world").Render()).Build() 130 | assert.Equal(t, "hello, world\n", mb.message.Content.Text.Text) 131 | } 132 | 133 | func TestWithSign(t *testing.T) { 134 | mb := NewMsgBuffer(MsgText) 135 | assert.Empty(t, mb.message.Sign) 136 | msg := mb.WithSign("xxx", 1661860880).Build() 137 | assert.NotEmpty(t, mb.message.Sign) 138 | assert.Equal(t, "QnWVTSBe6FmQDE0bG6X0mURbI+DnvVyu1h+j5dHOjrU=", msg.Sign) 139 | } 140 | 141 | func TestWithUUID(t *testing.T) { 142 | mb := NewMsgBuffer(MsgText) 143 | assert.Empty(t, mb.message.UUID) 144 | msg := mb.WithUUID("abc-def-0000").Build() 145 | assert.NotEmpty(t, mb.message.UUID) 146 | assert.Equal(t, "abc-def-0000", msg.UUID) 147 | } 148 | -------------------------------------------------------------------------------- /v2/msg_card_builder.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "github.com/go-lark/card-builder" 5 | "github.com/go-lark/card-builder/i18n" 6 | ) 7 | 8 | type i18nCardBuilder struct{} 9 | 10 | // CardBuilder . 11 | type CardBuilder struct { 12 | I18N *i18nCardBuilder 13 | } 14 | 15 | // Card wraps i18n card 16 | func (i18nCardBuilder) Card(blocks ...*i18n.LocalizedBlock) *i18n.Block { 17 | return i18n.Card(blocks...) 18 | } 19 | 20 | func (i18nCardBuilder) WithLocale(locale string, elements ...card.Element) *i18n.LocalizedBlock { 21 | return i18n.WithLocale(locale, elements...) 22 | } 23 | 24 | // Title wraps i18n title block 25 | func (i18nCardBuilder) LocalizedText(locale, s string) *i18n.LocalizedTextBlock { 26 | return i18n.LocalizedText(locale, s) 27 | } 28 | 29 | // NewCardBuilder . 30 | func NewCardBuilder() *CardBuilder { 31 | return &CardBuilder{ 32 | I18N: &i18nCardBuilder{}, 33 | } 34 | } 35 | 36 | // Card assigns elements 37 | func (CardBuilder) Card(elements ...card.Element) *card.Block { 38 | return card.Card(elements...) 39 | } 40 | 41 | // Action elements including Button, SelectMenu, Overflow, DatePicker, TimePicker, DatetimePicker 42 | func (CardBuilder) Action(actions ...card.Element) *card.ActionBlock { 43 | return card.Action(actions...) 44 | } 45 | 46 | // Button . 47 | func (CardBuilder) Button(text *card.TextBlock) *card.ButtonBlock { 48 | return card.Button(text) 49 | } 50 | 51 | // Confirm . 52 | func (CardBuilder) Confirm(title, text string) *card.ConfirmBlock { 53 | return card.Confirm(title, text) 54 | } 55 | 56 | // DatePicker . 57 | func (CardBuilder) DatePicker() *card.DatePickerBlock { 58 | return card.DatePicker() 59 | } 60 | 61 | // TimePicker . 62 | func (CardBuilder) TimePicker() *card.TimePickerBlock { 63 | return card.TimePicker() 64 | } 65 | 66 | // DatetimePicker . 67 | func (CardBuilder) DatetimePicker() *card.DatetimePickerBlock { 68 | return card.DatetimePicker() 69 | } 70 | 71 | // Div . 72 | func (CardBuilder) Div(fields ...*card.FieldBlock) *card.DivBlock { 73 | return card.Div(fields...) 74 | } 75 | 76 | // Field . 77 | func (CardBuilder) Field(text *card.TextBlock) *card.FieldBlock { 78 | return card.Field(text) 79 | } 80 | 81 | // Hr . 82 | func (CardBuilder) Hr() *card.HrBlock { 83 | return card.Hr() 84 | } 85 | 86 | // Img . 87 | func (CardBuilder) Img(key string) *card.ImgBlock { 88 | return card.Img(key) 89 | } 90 | 91 | // Note . 92 | func (CardBuilder) Note() *card.NoteBlock { 93 | return card.Note() 94 | } 95 | 96 | // Option . 97 | func (CardBuilder) Option(value string) *card.OptionBlock { 98 | return card.Option(value) 99 | } 100 | 101 | // Overflow . 102 | func (CardBuilder) Overflow(options ...*card.OptionBlock) *card.OverflowBlock { 103 | return card.Overflow(options...) 104 | } 105 | 106 | // SelectMenu . 107 | func (CardBuilder) SelectMenu(options ...*card.OptionBlock) *card.SelectMenuBlock { 108 | return card.SelectMenu(options...) 109 | } 110 | 111 | // Text . 112 | func (CardBuilder) Text(s string) *card.TextBlock { 113 | return card.Text(s) 114 | } 115 | 116 | // Markdown . 117 | func (CardBuilder) Markdown(s string) *card.MarkdownBlock { 118 | return card.Markdown(s) 119 | } 120 | 121 | // URL . 122 | func (CardBuilder) URL() *card.URLBlock { 123 | return card.URL() 124 | } 125 | 126 | // ColumnSet column set module 127 | func (CardBuilder) ColumnSet(columns ...*card.ColumnBlock) *card.ColumnSetBlock { 128 | return card.ColumnSet(columns...) 129 | } 130 | 131 | // Column column module 132 | func (CardBuilder) Column(elements ...card.Element) *card.ColumnBlock { 133 | return card.Column(elements...) 134 | } 135 | 136 | // ColumnSetAction column action module 137 | func (CardBuilder) ColumnSetAction(url *card.URLBlock) *card.ColumnSetActionBlock { 138 | return card.ColumnSetAction(url) 139 | } 140 | -------------------------------------------------------------------------------- /v2/msg_post_builder.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // PostContent . 4 | type PostContent map[string]PostBody 5 | 6 | // PostBody . 7 | type PostBody struct { 8 | Title string `json:"title"` 9 | Content [][]PostElem `json:"content"` 10 | } 11 | 12 | // PostElem . 13 | type PostElem struct { 14 | Tag string `json:"tag"` 15 | // For Text 16 | UnEscape *bool `json:"un_escape,omitempty"` 17 | Text *string `json:"text,omitempty"` 18 | Lines *int `json:"lines,omitempty"` 19 | // For Link 20 | Href *string `json:"href,omitempty"` 21 | // For At 22 | UserID *string `json:"user_id,omitempty"` 23 | // For Image 24 | ImageKey *string `json:"image_key,omitempty"` 25 | ImageWidth *int `json:"width,omitempty"` 26 | ImageHeight *int `json:"height,omitempty"` 27 | } 28 | 29 | const ( 30 | msgPostText = "text" 31 | msgPostLink = "a" 32 | msgPostAt = "at" 33 | msgPostImage = "img" 34 | ) 35 | 36 | // PostBuf . 37 | type PostBuf struct { 38 | Title string `json:"title"` 39 | Content []PostElem `json:"content"` 40 | } 41 | 42 | // MsgPostBuilder for build text buf 43 | type MsgPostBuilder struct { 44 | buf map[string]*PostBuf 45 | curLocale string 46 | } 47 | 48 | const defaultLocale = LocaleZhCN 49 | 50 | // NewPostBuilder creates a text builder 51 | func NewPostBuilder() *MsgPostBuilder { 52 | return &MsgPostBuilder{ 53 | buf: make(map[string]*PostBuf), 54 | curLocale: defaultLocale, 55 | } 56 | } 57 | 58 | // WithLocale switches to locale and returns self 59 | func (pb *MsgPostBuilder) WithLocale(locale string) *MsgPostBuilder { 60 | if _, ok := pb.buf[locale]; !ok { 61 | pb.buf[locale] = &PostBuf{} 62 | } 63 | 64 | pb.curLocale = locale 65 | return pb 66 | } 67 | 68 | // CurLocale switches to locale and returns the buffer of that locale 69 | func (pb *MsgPostBuilder) CurLocale() *PostBuf { 70 | return pb.WithLocale(pb.curLocale).buf[pb.curLocale] 71 | } 72 | 73 | // Title sets title 74 | func (pb *MsgPostBuilder) Title(title string) *MsgPostBuilder { 75 | pb.CurLocale().Title = title 76 | return pb 77 | } 78 | 79 | // TextTag creates a text tag 80 | func (pb *MsgPostBuilder) TextTag(text string, lines int, unescape bool) *MsgPostBuilder { 81 | pe := PostElem{ 82 | Tag: msgPostText, 83 | Text: &text, 84 | Lines: &lines, 85 | UnEscape: &unescape, 86 | } 87 | pb.CurLocale().Content = append(pb.CurLocale().Content, pe) 88 | return pb 89 | } 90 | 91 | // LinkTag creates a link tag 92 | func (pb *MsgPostBuilder) LinkTag(text, href string) *MsgPostBuilder { 93 | pe := PostElem{ 94 | Tag: msgPostLink, 95 | Text: &text, 96 | Href: &href, 97 | } 98 | pb.CurLocale().Content = append(pb.CurLocale().Content, pe) 99 | return pb 100 | } 101 | 102 | // AtTag creates an at tag 103 | func (pb *MsgPostBuilder) AtTag(text, userID string) *MsgPostBuilder { 104 | pe := PostElem{ 105 | Tag: msgPostAt, 106 | Text: &text, 107 | UserID: &userID, 108 | } 109 | pb.CurLocale().Content = append(pb.CurLocale().Content, pe) 110 | return pb 111 | } 112 | 113 | // ImageTag creates an image tag 114 | func (pb *MsgPostBuilder) ImageTag(imageKey string, imageWidth, imageHeight int) *MsgPostBuilder { 115 | pe := PostElem{ 116 | Tag: msgPostImage, 117 | ImageKey: &imageKey, 118 | ImageWidth: &imageWidth, 119 | ImageHeight: &imageHeight, 120 | } 121 | pb.CurLocale().Content = append(pb.CurLocale().Content, pe) 122 | return pb 123 | } 124 | 125 | // Clear all message 126 | func (pb *MsgPostBuilder) Clear() { 127 | pb.curLocale = defaultLocale 128 | pb.buf = make(map[string]*PostBuf) 129 | } 130 | 131 | // Render message 132 | func (pb *MsgPostBuilder) Render() *PostContent { 133 | content := make(PostContent) 134 | for locale, buf := range pb.buf { 135 | content[locale] = PostBody{ 136 | Title: buf.Title, 137 | Content: [][]PostElem{buf.Content}, 138 | } 139 | } 140 | return &content 141 | } 142 | 143 | // Len returns buf len 144 | func (pb MsgPostBuilder) Len() int { 145 | return len(pb.CurLocale().Content) 146 | } 147 | -------------------------------------------------------------------------------- /v2/msg_post_builder_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestPostLocale(t *testing.T) { 10 | pb := NewPostBuilder() 11 | assert.Equal(t, defaultLocale, pb.curLocale) 12 | pb.WithLocale(LocaleEnUS) 13 | assert.Equal(t, LocaleEnUS, pb.curLocale) 14 | pb.WithLocale(LocaleJaJP) 15 | assert.Equal(t, LocaleJaJP, pb.curLocale) 16 | } 17 | 18 | func TestPostTitle(t *testing.T) { 19 | pb := NewPostBuilder() 20 | pb.Title("title") 21 | assert.Equal(t, "title", pb.CurLocale().Title) 22 | } 23 | 24 | func TestPostTextTag(t *testing.T) { 25 | pb := NewPostBuilder() 26 | pb.TextTag("hello, world", 1, true) 27 | buf := pb.CurLocale().Content 28 | assert.Equal(t, "text", buf[0].Tag) 29 | assert.Equal(t, "hello, world", *(buf[0].Text)) 30 | assert.Equal(t, 1, *(buf[0].Lines)) 31 | assert.Equal(t, true, *(buf[0].UnEscape)) 32 | } 33 | 34 | func TestPostLinkTag(t *testing.T) { 35 | pb := NewPostBuilder() 36 | pb.LinkTag("hello, world", "https://www.toutiao.com/") 37 | buf := pb.CurLocale().Content 38 | assert.Equal(t, "a", buf[0].Tag) 39 | assert.Equal(t, "hello, world", *(buf[0].Text)) 40 | assert.Equal(t, "https://www.toutiao.com/", *(buf[0].Href)) 41 | } 42 | 43 | func TestPostAtTag(t *testing.T) { 44 | pb := NewPostBuilder() 45 | pb.AtTag("www", "123456") 46 | buf := pb.CurLocale().Content 47 | assert.Equal(t, "at", buf[0].Tag) 48 | assert.Equal(t, "www", *(buf[0].Text)) 49 | assert.Equal(t, "123456", *(buf[0].UserID)) 50 | } 51 | 52 | func TestPostImgTag(t *testing.T) { 53 | pb := NewPostBuilder() 54 | pb.ImageTag("d9f7d37e-c47c-411b-8ec6-9861132e6986", 320, 240) 55 | buf := pb.CurLocale().Content 56 | assert.Equal(t, "img", buf[0].Tag) 57 | assert.Equal(t, "d9f7d37e-c47c-411b-8ec6-9861132e6986", *(buf[0].ImageKey)) 58 | assert.Equal(t, 240, *(buf[0].ImageHeight)) 59 | assert.Equal(t, 320, *(buf[0].ImageWidth)) 60 | } 61 | 62 | func TestPostClearAndLen(t *testing.T) { 63 | pb := NewPostBuilder() 64 | pb.TextTag("hello, world", 1, true).LinkTag("link", "https://www.toutiao.com/") 65 | assert.Equal(t, 2, pb.Len()) 66 | pb.Clear() 67 | assert.Empty(t, pb.buf) 68 | assert.Equal(t, 0, pb.Len()) 69 | } 70 | 71 | func TestPostMultiLocaleContent(t *testing.T) { 72 | pb := NewPostBuilder() 73 | pb.Title("中文标题") 74 | assert.Equal(t, "中文标题", pb.CurLocale().Title) 75 | pb.TextTag("你好世界", 1, true).TextTag("其他内容", 1, true) 76 | assert.Equal(t, 2, pb.Len()) 77 | 78 | pb.WithLocale(LocaleEnUS).Title("en title") 79 | pb.TextTag("hello, world", 1, true).LinkTag("link", "https://www.toutiao.com/") 80 | assert.Equal(t, 2, pb.Len()) 81 | assert.Equal(t, "en title", pb.CurLocale().Title) 82 | 83 | content := pb.Render() 84 | t.Log(content) 85 | assert.Equal(t, "中文标题", (*content)[LocaleZhCN].Title) 86 | assert.Equal(t, "en title", (*content)[LocaleEnUS].Title) 87 | } 88 | -------------------------------------------------------------------------------- /v2/msg_template_builder.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // MsgTemplateBuilder for build template 4 | type MsgTemplateBuilder struct { 5 | id string 6 | versionName string 7 | data map[string]interface{} 8 | } 9 | 10 | // NewTemplateBuilder creates a text builder 11 | func NewTemplateBuilder() *MsgTemplateBuilder { 12 | return &MsgTemplateBuilder{} 13 | } 14 | 15 | // BindTemplate . 16 | func (tb *MsgTemplateBuilder) BindTemplate(id, versionName string, data map[string]interface{}) *TemplateContent { 17 | tb.id = id 18 | tb.versionName = versionName 19 | tb.data = data 20 | 21 | tc := &TemplateContent{ 22 | Type: "template", 23 | Data: templateData{ 24 | TemplateID: tb.id, 25 | TemplateVersionName: tb.versionName, 26 | }, 27 | } 28 | 29 | if data != nil { 30 | tc.Data.TemplateVariable = tb.data 31 | } 32 | 33 | return tc 34 | } 35 | -------------------------------------------------------------------------------- /v2/msg_template_builder_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestBindTemplate(t *testing.T) { 10 | b := NewTemplateBuilder() 11 | assert.Empty(t, b.id) 12 | assert.Empty(t, b.versionName) 13 | assert.Nil(t, b.data) 14 | 15 | _ = b.BindTemplate("AAqCYI07MQWh1", "1.0.0", map[string]interface{}{ 16 | "name": "志田千陽", 17 | }) 18 | assert.Equal(t, "AAqCYI07MQWh1", b.id) 19 | assert.Equal(t, "1.0.0", b.versionName) 20 | assert.NotEmpty(t, b.data) 21 | } 22 | -------------------------------------------------------------------------------- /v2/msg_text_builder.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // textElemType of a text buf 8 | type textElemType int 9 | 10 | type textElem struct { 11 | elemType textElemType 12 | content string 13 | } 14 | 15 | const ( 16 | // MsgText text only message 17 | msgText textElemType = iota 18 | // MsgAt @somebody 19 | msgAt 20 | // MsgAtAll @all 21 | msgAtAll 22 | // msgSpace space 23 | msgSpace 24 | ) 25 | 26 | // MsgTextBuilder for build text buf 27 | type MsgTextBuilder struct { 28 | buf []textElem 29 | } 30 | 31 | // NewTextBuilder creates a text builder 32 | func NewTextBuilder() *MsgTextBuilder { 33 | return &MsgTextBuilder{ 34 | buf: make([]textElem, 0), 35 | } 36 | } 37 | 38 | // Text adds simple texts 39 | func (tb *MsgTextBuilder) Text(text ...interface{}) *MsgTextBuilder { 40 | elem := textElem{ 41 | elemType: msgText, 42 | content: fmt.Sprint(text...), 43 | } 44 | tb.buf = append(tb.buf, elem) 45 | return tb 46 | } 47 | 48 | // Textln adds simple texts with a newline 49 | func (tb *MsgTextBuilder) Textln(text ...interface{}) *MsgTextBuilder { 50 | elem := textElem{ 51 | elemType: msgText, 52 | content: fmt.Sprintln(text...), 53 | } 54 | tb.buf = append(tb.buf, elem) 55 | return tb 56 | } 57 | 58 | // Textf adds texts with format 59 | func (tb *MsgTextBuilder) Textf(textFmt string, text ...interface{}) *MsgTextBuilder { 60 | elem := textElem{ 61 | elemType: msgText, 62 | content: fmt.Sprintf(textFmt, text...), 63 | } 64 | tb.buf = append(tb.buf, elem) 65 | return tb 66 | } 67 | 68 | // Mention @somebody 69 | func (tb *MsgTextBuilder) Mention(userID string) *MsgTextBuilder { 70 | elem := textElem{ 71 | elemType: msgAt, 72 | content: fmt.Sprintf("@user", userID), 73 | } 74 | tb.buf = append(tb.buf, elem) 75 | return tb 76 | } 77 | 78 | // MentionAll @all 79 | func (tb *MsgTextBuilder) MentionAll() *MsgTextBuilder { 80 | elem := textElem{ 81 | elemType: msgAtAll, 82 | content: "@all", 83 | } 84 | tb.buf = append(tb.buf, elem) 85 | return tb 86 | } 87 | 88 | // Clear all message 89 | func (tb *MsgTextBuilder) Clear() { 90 | tb.buf = make([]textElem, 0) 91 | } 92 | 93 | // Render message 94 | func (tb *MsgTextBuilder) Render() string { 95 | var text string 96 | for _, msg := range tb.buf { 97 | text += msg.content 98 | } 99 | return text 100 | } 101 | 102 | // Len returns buf len 103 | func (tb MsgTextBuilder) Len() int { 104 | return len(tb.buf) 105 | } 106 | -------------------------------------------------------------------------------- /v2/msg_text_builder_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestUseText(t *testing.T) { 10 | tb := NewTextBuilder() 11 | msg := tb.Text("hello, ", "world", 123).Render() 12 | assert.Equal(t, "hello, world123", tb.buf[0].content) 13 | assert.Equal(t, "hello, world123", msg) 14 | } 15 | 16 | func TestTextTextf(t *testing.T) { 17 | tb := NewTextBuilder() 18 | msg := tb.Textf("hello, %s: %d", "world", 1).Render() 19 | assert.Equal(t, "hello, world: 1", tb.buf[0].content) 20 | assert.Equal(t, "hello, world: 1", msg) 21 | } 22 | 23 | func TestTextTextln(t *testing.T) { 24 | tb := NewTextBuilder() 25 | msg := tb.Textln("hello", "world").Render() 26 | assert.Equal(t, "hello world\n", tb.buf[0].content) 27 | assert.Equal(t, "hello world\n", msg) 28 | } 29 | 30 | func TestTextMention(t *testing.T) { 31 | tb := NewTextBuilder() 32 | msg := tb.Text("hello, world").Mention("6454030812462448910").Render() 33 | assert.Equal(t, "hello, world@user", msg) 34 | } 35 | 36 | func TestTextMentionAll(t *testing.T) { 37 | tb := NewTextBuilder() 38 | msg := tb.Text("hello, world").MentionAll().Render() 39 | assert.Equal(t, "hello, world@all", msg) 40 | } 41 | 42 | func TestTextClearAndLen(t *testing.T) { 43 | tb := NewTextBuilder() 44 | tb.Text("hello, world").MentionAll() 45 | assert.Equal(t, 2, tb.Len()) 46 | tb.Clear() 47 | assert.Empty(t, tb.buf) 48 | assert.Equal(t, 0, tb.Len()) 49 | } 50 | -------------------------------------------------------------------------------- /v2/user.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | // UID types 4 | const ( 5 | UIDEmail = "email" 6 | UIDUserID = "user_id" 7 | UIDOpenID = "open_id" 8 | UIDChatID = "chat_id" 9 | UIDUnionID = "union_id" 10 | ) 11 | 12 | // OptionalUserID contains either openID, chatID, userID, or email 13 | type OptionalUserID struct { 14 | UIDType string 15 | RealID string 16 | } 17 | 18 | func withOneID(uidType, realID string) *OptionalUserID { 19 | return &OptionalUserID{ 20 | UIDType: uidType, 21 | RealID: realID, 22 | } 23 | } 24 | 25 | // WithEmail uses email as userID 26 | func WithEmail(email string) *OptionalUserID { 27 | return withOneID(UIDEmail, email) 28 | } 29 | 30 | // WithUserID uses userID as userID 31 | func WithUserID(userID string) *OptionalUserID { 32 | return withOneID(UIDUserID, userID) 33 | } 34 | 35 | // WithOpenID uses openID as userID 36 | func WithOpenID(openID string) *OptionalUserID { 37 | return withOneID(UIDOpenID, openID) 38 | } 39 | 40 | // WithChatID uses chatID as userID 41 | func WithChatID(chatID string) *OptionalUserID { 42 | return withOneID(UIDChatID, chatID) 43 | } 44 | 45 | // WithUnionID uses chatID as userID 46 | func WithUnionID(unionID string) *OptionalUserID { 47 | return withOneID(UIDUnionID, unionID) 48 | } 49 | -------------------------------------------------------------------------------- /v2/user_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestWithFunctions(t *testing.T) { 10 | emailUID := WithEmail(testUserEmail) 11 | assert.Equal(t, "email", emailUID.UIDType) 12 | assert.Equal(t, testUserEmail, emailUID.RealID) 13 | 14 | openIDUID := WithOpenID(testUserOpenID) 15 | assert.Equal(t, "open_id", openIDUID.UIDType) 16 | assert.Equal(t, testUserOpenID, openIDUID.RealID) 17 | 18 | chatIDUID := WithChatID(testGroupChatID) 19 | assert.Equal(t, "chat_id", chatIDUID.UIDType) 20 | assert.Equal(t, testGroupChatID, chatIDUID.RealID) 21 | 22 | fakeUID := "6893390418998738946" 23 | userIDUID := WithUserID(fakeUID) 24 | assert.Equal(t, "user_id", userIDUID.UIDType) 25 | assert.Equal(t, fakeUID, userIDUID.RealID) 26 | } 27 | -------------------------------------------------------------------------------- /v2/util.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "os" 7 | ) 8 | 9 | // DownloadFile downloads from a URL to local path 10 | func DownloadFile(path, url string) error { 11 | // Create the file 12 | out, err := os.Create(path) 13 | if err != nil { 14 | return err 15 | } 16 | defer out.Close() 17 | 18 | // Get the data 19 | resp, err := http.Get(url) 20 | if err != nil { 21 | return err 22 | } 23 | defer resp.Body.Close() 24 | 25 | // Write the body to file 26 | _, err = io.Copy(out, resp.Body) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /v2/util_test.go: -------------------------------------------------------------------------------- 1 | package lark 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestFileDownload(t *testing.T) { 12 | ts := time.Now().Unix() 13 | filename := fmt.Sprintf("/tmp/go-lark-ci-%d", ts) 14 | err := DownloadFile(filename, "https://s1-fs.pstatp.com/static-resource/v1/363e0009ef09d43d5a96~?image_size=72x72&cut_type=&quality=&format=png&sticker_format=.webp") 15 | if assert.NoError(t, err) { 16 | assert.FileExists(t, filename) 17 | } 18 | } 19 | --------------------------------------------------------------------------------