diff --git a/pkg/monitor/notifydrivers/feishu/cache.go b/pkg/monitor/notifydrivers/feishu/cache.go new file mode 100644 index 0000000000..f3fc13e17d --- /dev/null +++ b/pkg/monitor/notifydrivers/feishu/cache.go @@ -0,0 +1,68 @@ +// Copyright 2019 Yunion +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package feishu + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "time" +) + +type IExpirable interface { + CreatedAt() int64 + ExpiresIn() int64 +} + +type ICache interface { + Set(data IExpirable) error + Get(data IExpirable) error +} + +type FileCache struct { + Path string +} + +func NewFileCache(path string) *FileCache { + return &FileCache{ + Path: path, + } +} + +func (c *FileCache) Set(data IExpirable) error { + bytes, err := json.Marshal(data) + if err == nil { + ioutil.WriteFile(c.Path, bytes, 0644) + } + return err +} + +func (c *FileCache) Get(data IExpirable) error { + bytes, err := ioutil.ReadFile(c.Path) + if err != nil { + return err + } + err = json.Unmarshal(bytes, data) + if err != nil { + return err + } + created := data.CreatedAt() + expires := data.ExpiresIn() + // The operator '-120' can give us a head start on the expiration date + if time.Now().Unix() > created+expires-120 { + err = fmt.Errorf("Data is already expired") + } + return err +} diff --git a/pkg/monitor/notifydrivers/feishu/cache_test.go b/pkg/monitor/notifydrivers/feishu/cache_test.go new file mode 100644 index 0000000000..c8c524123c --- /dev/null +++ b/pkg/monitor/notifydrivers/feishu/cache_test.go @@ -0,0 +1,65 @@ +// Copyright 2019 Yunion +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package feishu + +import ( + "os" + "testing" + "time" +) + +func TestFileCache(t *testing.T) { + token := "atokeexamplenomeaning" + cache := NewFileCache(".test_auth_file") + defer func() { + os.Remove(".test_auth_file") + }() + t.Run("unexpired", func(t *testing.T) { + tokenIn := TenantAccesstoken{ + TenantAccessToken: token, + Expire: 3600, + Created: time.Now().Unix(), + } + err := cache.Set(tokenIn) + if err != nil { + t.Fatalf("cache set error: %s", err) + } + var tokenOut TenantAccesstoken + err = cache.Get(&tokenOut) + if err != nil { + t.Fatalf("cache get error: %s", err) + } + if tokenIn.TenantAccessToken != tokenOut.TenantAccessToken { + t.Fatalf("the token value stored in cache is incorrect") + } + }) + + t.Run("expired", func(t *testing.T) { + tokenIn := TenantAccesstoken{ + TenantAccessToken: token, + Expire: 119, + Created: time.Now().Unix(), + } + err := cache.Set(tokenIn) + if err != nil { + t.Fatalf("cache set error: %s", err) + } + var tokenOut TenantAccesstoken + err = cache.Get(&tokenOut) + if err == nil { + t.Fatalf("Getting expired token from cache should produce an error") + } + }) +} diff --git a/pkg/monitor/notifydrivers/feishu/client.go b/pkg/monitor/notifydrivers/feishu/client.go index 12d29edc83..c57a94b171 100644 --- a/pkg/monitor/notifydrivers/feishu/client.go +++ b/pkg/monitor/notifydrivers/feishu/client.go @@ -18,6 +18,7 @@ import ( "context" "fmt" "net/http" + "time" "yunion.io/x/jsonutils" "yunion.io/x/pkg/errors" @@ -32,6 +33,8 @@ const ( ApiChatList = "https://open.feishu.cn/open-apis/chat/v4/list" // 机器人发送消息 ApiRobotSendMessage = "https://open.feishu.cn/open-apis/message/v4/send/" + // 使用手机号或邮箱获取用户ID + ApiFetchUserID = "https://open.feishu.cn/open-apis/user/v1/batch_get_id" ) var ( @@ -75,7 +78,10 @@ func GetTenantAccessTokenInternal(appId string, appSecret string) (*TenantAccess } type Tenant struct { + AppId string + AppSecret string AccessToken string + Cache ICache } func BuildTokenHeader(token string) http.Header { @@ -85,13 +91,34 @@ func BuildTokenHeader(token string) http.Header { } func NewTenant(appId, appSecret string) (*Tenant, error) { - resp, err := GetTenantAccessTokenInternal(appId, appSecret) - if err != nil { - return nil, err + t := &Tenant{ + AppId: appId, + AppSecret: appSecret, + AccessToken: "", + Cache: nil, } - return &Tenant{ - AccessToken: resp.TenantAccessToken, - }, nil + t.Cache = NewFileCache(fmt.Sprintf(".%s_auth_file", appId)) + err := t.RefreshAccessToken() + return t, err +} + +// RefreshAccessToken is to get a valid access token +func (t *Tenant) RefreshAccessToken() error { + var data TenantAccesstoken + err := t.Cache.Get(&data) + if err == nil { + t.AccessToken = data.TenantAccessToken + return nil + } + + tokenResp, err := GetTenantAccessTokenInternal(t.AppId, t.AppSecret) + if err == nil { + t.AccessToken = tokenResp.TenantAccessToken + data = tokenResp.TenantAccesstoken + data.Created = time.Now().Unix() + err = t.Cache.Set(&data) + } + return err } func (t *Tenant) request(method httputils.THttpMethod, url string, data jsonutils.JSONObject, out CommonResponser) error { @@ -104,7 +131,13 @@ func (t *Tenant) request(method httputils.THttpMethod, url string, data jsonutil } func (t *Tenant) get(url string, query jsonutils.JSONObject, out CommonResponser) error { - return t.request(httputils.GET, url, query, out) + if query != nil { + qs := query.QueryString() + if len(qs) > 0 { + url = fmt.Sprintf("%s?%s", url, qs) + } + } + return t.request(httputils.GET, url, nil, out) } func (t *Tenant) post(url string, body jsonutils.JSONObject, out CommonResponser) error { @@ -130,3 +163,23 @@ func (t *Tenant) SendMessage(msg MsgReq) (*MsgResp, error) { err := t.post(ApiRobotSendMessage, body, resp) return resp, err } + +// UserIdByMobile query the open id of user by mobile number. https://open.feishu.cn/document/ukTMukTMukTM/uUzMyUjL1MjM14SNzITN +func (t *Tenant) UserIdByMobile(mobile string) (string, error) { + query := jsonutils.NewDict() + query.Set("mobiles", jsonutils.NewString(mobile)) + resp := new(UserIDResp) + err := t.get(ApiFetchUserID, query, resp) + if err != nil { + return "", err + } + if len(resp.Data.MobilesNotExist) != 0 { + return "", errors.Wrapf(errors.ErrNotFound, "no such user whose mobile is %s", mobile) + } + list, err := resp.Data.MobileUsers.GetArray(mobile) + if err != nil { + return "", errors.Wrap(err, "jsonutils.JSONObject.GetArray") + } + // len(list) must be positive + return list[0].GetString("open_id") +} diff --git a/pkg/monitor/notifydrivers/feishu/types.go b/pkg/monitor/notifydrivers/feishu/types.go index d2fa517ab9..8b27828896 100644 --- a/pkg/monitor/notifydrivers/feishu/types.go +++ b/pkg/monitor/notifydrivers/feishu/types.go @@ -14,6 +14,8 @@ package feishu +import "yunion.io/x/jsonutils" + type CommonResp struct { Code int `json:"code"` Msg string `json:"msg"` @@ -34,8 +36,21 @@ type CommonResponser interface { type TenantAccesstokenResp struct { CommonResp + TenantAccesstoken +} + +type TenantAccesstoken struct { TenantAccessToken string `json:"tenant_access_token"` Expire int64 `json:"expire"` + Created int64 +} + +func (t TenantAccesstoken) CreatedAt() int64 { + return t.Created +} + +func (t TenantAccesstoken) ExpiresIn() int64 { + return t.Expire } type GroupListResp struct { @@ -224,3 +239,16 @@ type MsgResp struct { type MsgRespData struct { MessageId string `json:"message_id"` } + +type UserIDResp struct { + CommonResp + + Data UserIDRespData `json:"data"` +} + +type UserIDRespData struct { + EmailUsers jsonutils.JSONObject `json:"email_users"` + EmailsNotExist []string `json:"emails_not_exist"` + MobileUsers jsonutils.JSONObject `json:"mobile_users"` + MobilesNotExist []string `json:"mobiles_not_exist"` +}