mirror of
https://github.com/yunionio/cloudpods.git
synced 2026-09-21 06:09:39 +08:00
Merge pull request #5330 from rainzm/feature/feishu_single_user
feat: Add useridByMobile and accessToken cache for feishu
This commit is contained in:
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user