Merge pull request #5330 from rainzm/feature/feishu_single_user

feat: Add useridByMobile and accessToken cache for feishu
This commit is contained in:
Zexi Li
2020-03-05 14:17:41 +08:00
committed by GitHub
4 changed files with 221 additions and 7 deletions
+68
View File
@@ -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
}
@@ -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")
}
})
}
+60 -7
View File
@@ -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")
}
+28
View File
@@ -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"`
}