commit a519daf9f1a4159438465a9e8244c1911246db30 Author: suguo.yao Date: Thu Nov 18 18:56:31 2021 +0800 sns及refreshaccesstoken测试通过 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2751f4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof +go.sum +.auth_file \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..11e9f51 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Hugo Zhu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4018392 --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# DingTalk Open API golang SDK + +![image](http://static.dingtalk.com/media/lALOAQ6nfSvM5Q_229_43.png) + +Check out DingTalk Open API document at: https://ding-doc.dingtalk.com/ + +## Usage + +Fetch the SDK +``` +export GOPATH=`pwd` +go get myschools.me/suguo/godingtalk +``` + +### Example code to send a micro app message + +``` +package main + +import ( + "myschools.me/suguo/godingtalk" + "log" + "os" +) + +func main() { + c := godingtalk.NewDingTalkClient(os.Getenv("corpid"), os.Getenv("corpsecret")) + c.RefreshAccessToken() + err := c.SendAppMessage(os.Args[1], os.Args[2], os.Args[3]) + if err != nil { + log.Println(err) + } +} +``` + + +## Guide + +Step-by-step Guide to use this SDK + +http://hugozhu.myalert.info/2016/05/02/66-use-dingtalk-golang-sdk-to-send-message-on-pi.html + +## Tools + +**ding_alert** : Command line tool to send app/text/oa ... messages + +``` +export GOPATH=`pwd` +go get myschools.me/suguo/godingtalk/demo/ding_alert + +export corpid=<组织的corpid 通过 https://oa.dingtalk.com 获取> +export corpsecret=<组织的corpsecret 通过 https://oa.dingtalk.com 获取> + +./bin/ding_alert +Usage of ./bin/ding_alert: + -agent string + agent Id (default "22194403") + -chat string + chat id (default "chat6a93bc1ee3b7d660d372b1b877a9de62") + -file string + file path for media message + -link string + link url (default "http://hugozhu.myalert.info/dingtalk") + -sender string + sender id (default "011217462940") + -text string + text for link message (default "This is link text") + -title string + title for link message (default "This is link title") + -touser string + touser id (default "0420506555") + -type string + message type (app, text, image, voice, link, oa) (default "app") + +``` + +**github**: Deliver Github webhook events to DingTalk, which can be deployed on Google AppEngine. + +more info at: http://hugozhu.myalert.info/2016/05/15/67-use-free-google-cloud-service-to-deliver-github-webhook-events-to-dingtalk.html + +``` +export GOPATH=`pwd` +go get myschools.me/suguo/godingtalk/demo/github/appengine +``` + +Modify `app.yaml` + +``` +cd src/myschools.me/suguo/godingtalk/demo/github/appengine +cat app.yaml +application: github-alert- +version: 1 +runtime: go +api_version: go1 +env_variables: + CORP_ID: '<从 http://oa.dingtalk.com 获取>' + CORP_SECRET: '<从 http://oa.dingtalk.com 获取>' + GITHUB_WEBHOOK_SECRET: '<从 http://github.com/ 获取>' + SENDER_ID: '<从 http://open.dingtalk.com 调用api获取>' + CHAT_ID: '<从 http://open.dingtalk.com 调用api获取>' +handlers: +- url: /.* + script: _go_app + +``` + + + diff --git a/api_attendance.go b/api_attendance.go new file mode 100644 index 0000000..3954b29 --- /dev/null +++ b/api_attendance.go @@ -0,0 +1,104 @@ +/* + * Author Kevin Zhu + * + * Direct questions, comments to + */ + +package godingtalk + +import ( + "errors" + "time" +) + +type Attendance struct { + GmtModifed int64 `json:"gmtModified"` //: 1492594486000, + IsLegal string `json:"isLegal"` //: "N", + BaseCheckTime int64 `json:"baseCheckTime"` //: 1492568460000, + ID int64 `json:"id"` //: 933202551, + UserAddress string `json:"userAddress"` //: "北京市朝阳区崔各庄镇阿里中心.望京A座阿里巴巴绿地中心", + UID string `json:"userId"` //: "manager7078", + CheckType string `json:"checkType"` //: "OnDuty", + TimeResult string `json:"timeResult"` //: "Normal", + DeviceID string `json:"deviceId"` // :"cb7ace07d52fe9be14f4d8bec5e1ba79" + CorpID string `json:"corpId"` //: "ding7536bfee6fb1fa5a35c2f4657eb6378f", + SourceType string `json:"sourceType"` //: "USER", + WorkDate int64 `json:"workDate"` //: 1492531200000, + PlanCheckTime int64 `json:"planCheckTime"` //: 1492568497000, + GmtCreate int64 `json:"gmtCreate"` //: 1492594486000, + LocaltionMethod string `json:"locationMethod"` //: "MAP", + LocationResult string `json:"locationResult"` //: "Outside", + UserLongitude float64 `json:"userLongitude"` //: 116.486888, + PlanID int `json:"planId"` //: 4550269081, + GroupID int `json:"groupId"` //: 121325603, + UserAccuracy int `json:"userAccuracy"` //: 65, + UserCheckTime int64 `json:"userCheckTime"` //: 1492568497000, + UserLatitude float64 `json:"userLatitude"` //: 39.999946, + ProcInstID string `json:"procInstId"` //: "cb992267-9b70" + ApproveID int `json:"approveId"` // string, `json:""`//关联的审批id + ClassId int `json:"classId"` //考勤班次id,没有的话表示该次打卡不在排班内 + UserSsid string `json:"userSsid"` //用户打卡wifi SSID + UserMacAddr string `json:"userMacAddr"` //用户打卡wifi Mac地址 + BaseAddress string `json:"baseAddress"` //基准地址 + BaseLongitude float32 `json:"baseLongitude"` // 基准经度 + BaseLatitude float32 `json:"baseLatitude"` // 基准纬度 + BaseAccuracy int `json:"baseAccuracy"` // 基准定位精度 + BaseSsid string `json:"baseSsid"` //基准wifi ssid + BaseMacAddr string `json:"baseMacAddr"` //基准 Mac 地址 + OutsideRemark string `json:"outsideRemark"` //打卡备注 +} + +type listAttendanceRecordResp struct { + OAPIResponse + Records []Attendance `json:"recordresult"` +} + +// 获取所有的打卡记录,该员工当天如果打卡10条,那么10条都将返回 +func (c *DingTalkClient) ListAttendanceRecord(ulist []string, dateFrom time.Time, dateTo time.Time) ([]Attendance, error) { + var resp listAttendanceRecordResp + if len(ulist) > 50 || len(ulist) < 1 { + return nil, errors.New("Users can't more than 50 or less than 1") + } + + if !dateFrom.Before(dateTo) { + return nil, errors.New("FromDate must before ToDate") + } + if time.Duration(dateTo.UnixNano()-dateFrom.UnixNano()).Hours() > float64(7*24) { + return nil, errors.New("Can't more than 6 days at once") + } + + request := map[string]interface{}{ + "checkDateFrom": dateFrom.Format("2006-01-02 15:04:05"), // "yyyy-MM-dd hh:mm:ss", + "checkDateTo": dateTo.Format("2006-01-02 15:04:05"), // "yyyy-MM-dd hh:mm:ss", + "userIds": ulist, // 企业内的员工id列表,最多不能超过50个 + } + return resp.Records, c.httpRPC("/attendance/listRecord", nil, request, &resp) +} + +type listAttendanceResultResp struct { + OAPIResponse + HasMore bool `json:"hasMore"` + Records []Attendance `json:"recordresult"` +} + +// 即使员工在这期间打了多次,该接口也只会返回两条记录,包括上午的打卡结果和下午的打卡结果 +// 用户如果为空则获取所有用户 +func (c *DingTalkClient) ListAttendanceResult(ulist []string, dateFrom, dateTo time.Time, offset, lmt int64) (listAttendanceResultResp, error) { + var resp listAttendanceResultResp + if time.Duration(dateTo.UnixNano()-dateFrom.UnixNano()).Hours() > float64(7*24) { + return resp, errors.New("Can't more than 7 days at once") + } + + if !dateFrom.Before(dateTo) { + return resp, errors.New("FromDate must before ToDate") + } + + request := map[string]interface{}{ + "workDateFrom": dateFrom.Format("2006-01-02 15:04:05"), // "yyyy-MM-dd hh:mm:ss", + "workDateTo": dateTo.Format("2006-01-02 15:04:05"), // "yyyy-MM-dd hh:mm:ss", + "userIdList": ulist, // ["员工UserId列表"], 必填,与offset和limit配合使用,不传表示分页获取全员的数据 + "offset": offset, // 必填,第一次传0,如果还有多余数据,下次传之前的offset加上limit的值 + "limit": lmt, // 最多50 + } + return resp, c.httpRPC("/attendance/list", nil, request, &resp) +} diff --git a/api_attendance_test.go b/api_attendance_test.go new file mode 100644 index 0000000..b5c9052 --- /dev/null +++ b/api_attendance_test.go @@ -0,0 +1,28 @@ +package godingtalk + +import ( + "testing" + "time" +) + +func TestListAttendanceRecord(t *testing.T) { + dataFrom, _ := time.Parse("2006-01-02", "2018-03-06") + dataTo, _ := time.Parse("2006-01-02", "2018-03-10") + records, err := c.ListAttendanceRecord([]string{"085354234826136236"}, dataFrom, dataTo) + if err != nil { + t.Error(err) + } else if len(records) > 0 { + t.Logf("%+v\n", records) + } +} + +func TestListAttendanceResult(t *testing.T) { + dataFrom, _ := time.Parse("2006-01-02", "2018-03-06") + dataTo, _ := time.Parse("2006-01-02", "2018-03-10") + resp, err := c.ListAttendanceResult([]string{"085354234826136236"}, dataFrom, dataTo, 0, 2) + if err != nil { + t.Error(err) + } else if len(resp.Records) > 0 { + t.Logf("%+v\n", resp.Records[0]) + } +} diff --git a/api_calendar.go b/api_calendar.go new file mode 100644 index 0000000..1f32f1d --- /dev/null +++ b/api_calendar.go @@ -0,0 +1,59 @@ +package godingtalk + +import "time" + +type Event struct { + OAPIResponse + Id string + Location string + Summary string + Description string + Start struct { + DateTime string `json:"date_time"` + } + End struct { + DateTime string `json:"date_time"` + } +} + +type ListEventsResponse struct { + OAPIResponse + Success bool `json:"success"` + Result struct { + Events []Event `json:"items"` + Summary string `json:"summary"` + NextPageToken string `json:"next_page_token"` + } `json:"result"` +} +type CalendarTime struct { + TimeZone string `json:"time_zone"` + Date string `json:"date_time"` +} + +type CalendarRequest struct { + TimeMax CalendarTime `json:"time_max"` + TimeMin CalendarTime `json:"time_min"` + StaffId string `json:"user_id"` +} + +func (c *DingTalkClient) ListEvents(staffid string, from time.Time, to time.Time) (events []Event, err error) { + location := time.Now().Location().String() + timeMin := CalendarTime{ + TimeZone: location, + Date: from.Format("2006-01-02T15:04:05Z0700"), + } + timeMax := CalendarTime{ + TimeZone: location, + Date: to.Format("2006-01-02T15:04:05Z0700"), + } + + data := CalendarRequest{ + TimeMax: timeMax, + TimeMin: timeMin, + StaffId: staffid, + } + var resp ListEventsResponse + err = c.httpRPC("topapi/calendar/list", nil, data, &resp) + events = resp.Result.Events + return events, err +} diff --git a/api_callback.go b/api_callback.go new file mode 100644 index 0000000..85f6a44 --- /dev/null +++ b/api_callback.go @@ -0,0 +1,49 @@ +package godingtalk + +type Callback struct { + OAPIResponse + Token string + AES_KEY string `json:"aes_key"` + URL string + Callbacks []string `json:"call_back_tag"` +} + +//RegisterCallback is 注册事件回调接口 +func (c *DingTalkClient) RegisterCallback(callbacks []string, token string, aes_key string, callbackURL string) error { + var data OAPIResponse + request := map[string]interface{}{ + "call_back_tag": callbacks, + "token": token, + "aes_key": aes_key, + "url": callbackURL, + } + err := c.httpRPC("call_back/register_call_back", nil, request, &data) + return err +} + +//UpdateCallback is 更新事件回调接口 +func (c *DingTalkClient) UpdateCallback(callbacks []string, token string, aes_key string, callbackURL string) error { + var data OAPIResponse + request := map[string]interface{}{ + "call_back_tag": callbacks, + "token": token, + "aes_key": aes_key, + "url": callbackURL, + } + err := c.httpRPC("call_back/update_call_back", nil, request, &data) + return err +} + +//DeleteCallback is 删除事件回调接口 +func (c *DingTalkClient) DeleteCallback() error { + var data OAPIResponse + err := c.httpRPC("call_back/delete_call_back", nil, nil, &data) + return err +} + +//ListCallback is 查询事件回调接口 +func (c *DingTalkClient) ListCallback() (Callback, error) { + var data Callback + err := c.httpRPC("call_back/get_call_back", nil, nil, &data) + return data, err +} diff --git a/api_callback_test.go b/api_callback_test.go new file mode 100644 index 0000000..ed82d9f --- /dev/null +++ b/api_callback_test.go @@ -0,0 +1,28 @@ +package godingtalk + +import ( + "testing" +) + +func TestRegisterCallback(t *testing.T) { + err := c.UpdateCallback([]string{"user_modify_org"}, "hello", "1234567890123456789012345678901234567890aes", "http://go.myalert.info:8888/dingtalk/callback/") + if err != nil { + t.Log(err) + } + err = c.DeleteCallback() + if err != nil { + t.Log(err) + } + err = c.RegisterCallback([]string{"user_add_org"}, "hello", "1234567890123456789012345678901234567890aes", "http://go.myalert.info:8888/dingtalk/callback/") + if err != nil { + t.Log(err) + } +} + +func TestListCallback(t *testing.T) { + data, err := c.ListCallback() + if err != nil { + t.Log(err) + } + t.Log(data) +} diff --git a/api_contact.go b/api_contact.go new file mode 100644 index 0000000..39aacd4 --- /dev/null +++ b/api_contact.go @@ -0,0 +1,146 @@ +package godingtalk + +import ( + "fmt" + "net/url" +) + +type User struct { + OAPIResponse + Userid string + Name string + Mobile string + Tel string + Remark string + Order int + IsAdmin bool + IsBoss bool + IsLeader bool + Active bool + Department []int + Position string + Email string + OrgEmail string + Avatar string + Extattr interface{} +} + +type UserList struct { + OAPIResponse + HasMore bool + Userlist []User +} + +type Department struct { + OAPIResponse + Id int + Name string + ParentId int + Order int + DeptPerimits string + UserPerimits string + OuterDept bool + OuterPermitDepts string + OuterPermitUsers string + OrgDeptOwner string + DeptManagerUseridList string +} + +type DepartmentList struct { + OAPIResponse + Departments []Department `json:"department"` +} + +// DepartmentList is 获取部门列表 +func (c *DingTalkClient) DepartmentList() (DepartmentList, error) { + var data DepartmentList + err := c.httpRPC("department/list", nil, nil, &data) + return data, err +} + +//DepartmentDetail is 获取部门详情 +func (c *DingTalkClient) DepartmentDetail(id int) (Department, error) { + var data Department + params := url.Values{} + params.Add("id", fmt.Sprintf("%d", id)) + err := c.httpRPC("department/get", params, nil, &data) + return data, err +} + +//UserList is 获取部门成员 +func (c *DingTalkClient) UserList(departmentID, offset, size int) (UserList, error) { + var data UserList + if size > 100 { + return data, fmt.Errorf("size 最大100") + } + + params := url.Values{} + params.Add("department_id", fmt.Sprintf("%d", departmentID)) + params.Add("offset", fmt.Sprintf("%d", offset)) + params.Add("size", fmt.Sprintf("%d", size)) + err := c.httpRPC("user/list", params, nil, &data) + return data, err +} + +//CreateChat is +func (c *DingTalkClient) CreateChat(name string, owner string, useridlist []string) (string, error) { + var data struct { + OAPIResponse + Chatid string + } + request := map[string]interface{}{ + "name": name, + "owner": owner, + "useridlist": useridlist, + } + err := c.httpRPC("chat/create", nil, request, &data) + return data.Chatid, err +} + +//UserInfoByCode 校验免登录码并换取用户身份 +func (c *DingTalkClient) UserInfoByCode(code string) (User, error) { + var data User + params := url.Values{} + params.Add("code", code) + err := c.httpRPC("user/getuserinfo", params, nil, &data) + return data, err +} + +//UserInfoByUserId 获取用户详情 +func (c *DingTalkClient) UserInfoByUserId(userid string) (User, error) { + var data User + params := url.Values{} + params.Add("userid", userid) + err := c.httpRPC("user/get", params, nil, &data) + return data, err +} + +//UseridByUnionId 通过UnionId获取玩家Userid +func (c *DingTalkClient) UseridByUnionId(unionid string) (string, error) { + var data struct { + OAPIResponse + UserID string `json:"userid"` + } + + params := url.Values{} + params.Add("unionid", unionid) + err := c.httpRPC("user/getUseridByUnionid", params, nil, &data) + if err != nil { + return "", err + } + + return data.UserID, err +} + +//UseridByMobile 通过手机号获取Userid +func (c *DingTalkClient) UseridByMobile(mobile string) (string, error) { + var data struct { + OAPIResponse + UserID string `json:"userid"` + } + + params := url.Values{} + params.Add("mobile", mobile) + err := c.httpRPC("user/get_by_mobile", params, nil, &data) + return data.UserID, err +} diff --git a/api_encryption.go b/api_encryption.go new file mode 100644 index 0000000..712b019 --- /dev/null +++ b/api_encryption.go @@ -0,0 +1,34 @@ +package godingtalk + +//DataMessage 服务端加密、解密消息 +type DataMessage struct { + OAPIResponse + Data string +} + + +//Encrypt is 服务端加密 +func (c *DingTalkClient) Encrypt(str string) (string, error) { + var data DataMessage + request := map[string]interface{}{ + "data": str, + } + err := c.httpRPC("encryption/encrypt", nil, request, &data) + if err!=nil { + return "", err + } + return data.Data, nil +} + +//Decrypt is 服务端解密 +func (c *DingTalkClient) Decrypt(str string) (string, error) { + var data DataMessage + request := map[string]interface{}{ + "data": str, + } + err := c.httpRPC("encryption/decrypt", nil, request, &data) + if err!=nil { + return "", err + } + return data.Data, nil +} \ No newline at end of file diff --git a/api_encryption_test.go b/api_encryption_test.go new file mode 100644 index 0000000..e800d8b --- /dev/null +++ b/api_encryption_test.go @@ -0,0 +1,14 @@ +package godingtalk + +import ( + "testing" +) + +func TestEncryption(t *testing.T) { + str, err := c.Encrypt("Hello") + if err!=nil { + t.Log(err) + } else { + t.Log(str) + } +} diff --git a/api_file.go b/api_file.go new file mode 100644 index 0000000..04ac63c --- /dev/null +++ b/api_file.go @@ -0,0 +1,38 @@ +package godingtalk + +import ( + "bytes" + "fmt" + "io" + "net/url" +) + +/** + * https://open-doc.dingtalk.com/docs/doc.htm?spm=a219a.7629140.0.0.UeYQVr&treeId=172&articleId=104970&docType=1 + * TODO: not completed yet + **/ + +//FileResponse is +type FileResponse struct { + OAPIResponse + Code int + Msg string + UploadID string `json:"uploadid"` + Writer io.Writer +} + +func (f *FileResponse) getWriter() io.Writer { + return f.Writer +} + +//CreateFile is to create a new file in Ding Space +func (c *DingTalkClient) CreateFile(size int64) (file FileResponse, err error) { + buf := bytes.Buffer{} + file = FileResponse{ + Writer: &buf, + } + params := url.Values{} + params.Add("size", fmt.Sprintf("%d", size)) + err = c.httpRPC("file/upload/create", params, nil, &file) + return file, err +} diff --git a/api_file_test.go b/api_file_test.go new file mode 100644 index 0000000..586f861 --- /dev/null +++ b/api_file_test.go @@ -0,0 +1,10 @@ +package godingtalk + +import ( + "testing" +) + +func TestCreateFile(t *testing.T) { + file, err := c.CreateFile(1024) + t.Log(file, err) +} diff --git a/api_media.go b/api_media.go new file mode 100644 index 0000000..633ee4f --- /dev/null +++ b/api_media.go @@ -0,0 +1,44 @@ +package godingtalk + +import ( + "io" + "net/url" + "time" +) + +//MediaResponse is +type MediaResponse struct { + OAPIResponse + Type string + MediaID string `json:"media_id"` + Writer io.Writer +} + +func (m *MediaResponse) getWriter() io.Writer { + return m.Writer +} + +//UploadMedia is to upload media file to DingTalk +func (c *DingTalkClient) UploadMedia(mediaType string, filename string, reader io.Reader) (media MediaResponse, err error) { + upload := UploadFile{ + FieldName: "media", + FileName: filename, + Reader: reader, + } + params := url.Values{} + params.Add("type", mediaType) + c.HTTPClient.Timeout = 120 * time.Second + err = c.httpRPC("media/upload", params, upload, &media) + return media, err +} + +//DownloadMedia is to download a media file from DingTalk +func (c *DingTalkClient) DownloadMedia(mediaID string, write io.Writer) error { + var data MediaResponse + data.Writer = write + params := url.Values{} + params.Add("media_id", mediaID) + c.HTTPClient.Timeout = 120 * time.Second + err := c.httpRPC("media/get", params, nil, &data) + return err +} diff --git a/api_message.go b/api_message.go new file mode 100644 index 0000000..6882209 --- /dev/null +++ b/api_message.go @@ -0,0 +1,259 @@ +package godingtalk + +import ( + "net/url" + "strconv" +) + +//SendAppMessage is 发送企业会话消息 +func (c *DingTalkClient) SendAppMessage(agentID string, touser string, msg string) error { + if agentID == "" { + agentID = c.AgentID + } + var data OAPIResponse + request := map[string]interface{}{ + "touser": touser, + "agentid": agentID, + "msgtype": "text", + "text": map[string]interface{}{ + "content": msg, + }, + } + err := c.httpRPC("message/send", nil, request, &data) + return err +} + +//SendAppOAMessage is 发送OA消息 +func (c *DingTalkClient) SendAppOAMessage(agentID string, touser string, msg OAMessage) error { + if agentID == "" { + agentID = c.AgentID + } + var data OAPIResponse + request := map[string]interface{}{ + "touser": touser, + "agentid": agentID, + "msgtype": "oa", + "oa": msg, + } + err := c.httpRPC("message/send", nil, request, &data) + return err +} + +// ActionCardMessage +func (c *DingTalkClient) SendOverAllActionCardMessage(agentID string, touser string, msg OverAllActionCardMessage) error { + if agentID == "" { + agentID = c.AgentID + } + var data OAPIResponse + request := map[string]interface{}{ + "touser": touser, + "agentid": agentID, + "msgtype": "action_card", + "action_card": msg, + } + err := c.httpRPC("message/send", nil, request, &data) + return err +} + +func (c *DingTalkClient) SendIndependentActionCardMessage(agentID string, touser string, msg IndependentActionCardMessage) error { + if agentID == "" { + agentID = c.AgentID + } + var data OAPIResponse + request := map[string]interface{}{ + "touser": touser, + "agentid": agentID, + "msgtype": "action_card", + "action_card": msg, + } + err := c.httpRPC("message/send", nil, request, &data) + return err +} + +//SendAppLinkMessage is 发送企业会话链接消息 +func (c *DingTalkClient) SendAppLinkMessage(agentID, touser string, title, text string, picUrl, url string) error { + if agentID == "" { + agentID = c.AgentID + } + var data OAPIResponse + request := map[string]interface{}{ + "touser": touser, + "agentid": agentID, + "msgtype": "link", + "link": map[string]string{ + "messageUrl": url, + "picUrl": picUrl, + "title": title, + "text": text, + }, + } + err := c.httpRPC("message/send", nil, request, &data) + return err +} + +//SendTextMessage is 发送普通文本消息 +func (c *DingTalkClient) SendTextMessage(sender string, cid string, msg string) (data MessageResponse, err error) { + request := map[string]interface{}{ + "chatid": cid, + "sender": sender, + "msgtype": "text", + "text": map[string]interface{}{ + "content": msg, + }, + } + err = c.httpRPC("chat/send", nil, request, &data) + return data, err +} + +//SendImageMessage is 发送图片消息 +func (c *DingTalkClient) SendImageMessage(sender string, cid string, mediaID string) (data MessageResponse, err error) { + request := map[string]interface{}{ + "chatid": cid, + "sender": sender, + "msgtype": "image", + "image": map[string]string{ + "media_id": mediaID, + }, + } + err = c.httpRPC("chat/send", nil, request, &data) + return data, err +} + +//SendVoiceMessage is 发送语音消息 +func (c *DingTalkClient) SendVoiceMessage(sender string, cid string, mediaID string, duration string) (data MessageResponse, err error) { + request := map[string]interface{}{ + "chatid": cid, + "sender": sender, + "msgtype": "voice", + "voice": map[string]string{ + "media_id": mediaID, + "duration": duration, + }, + } + err = c.httpRPC("chat/send", nil, request, &data) + return data, err +} + +//SendFileMessage is 发送文件消息 +func (c *DingTalkClient) SendFileMessage(sender string, cid string, mediaID string) (data MessageResponse, err error) { + request := map[string]interface{}{ + "chatid": cid, + "sender": sender, + "msgtype": "file", + "file": map[string]string{ + "media_id": mediaID, + }, + } + err = c.httpRPC("chat/send", nil, request, &data) + return data, err +} + +//SendLinkMessage is 发送链接消息 +func (c *DingTalkClient) SendLinkMessage(sender string, cid string, mediaID string, url string, title string, text string) (data MessageResponse, err error) { + request := map[string]interface{}{ + "chatid": cid, + "sender": sender, + "msgtype": "link", + "link": map[string]string{ + "messageUrl": url, + "picUrl": mediaID, + "title": title, + "text": text, + }, + } + err = c.httpRPC("chat/send", nil, request, &data) + return data, err +} + + +// OverAllActionCardMessage 整体跳转ActionCard +type OverAllActionCardMessage struct { + Title string `json:"title"` + MarkDown string `json:"markdown"` + SingleTitle string `json:"single_title"` + SingleUrl string `json:"single_url"` +} + +// IndependentActionCardMessage 独立跳转ActionCard +type IndependentActionCardMessage struct { + Title string `json:"title"` + MarkDown string `json:"markdown"` + BtnOrientation string `json:"btn_orientation"` + BtnJsonList []ActionCardMessageBtnList `json:"btn_json_list"` +} + +type ActionCardMessageBtnList struct { + Title string `json:"title,omitempty"` + ActionUrl string `json:"action_url,omitempty"` +} + +func (m *IndependentActionCardMessage) AppendBtnItem(title string, action_url string) { + f := ActionCardMessageBtnList{Title: title, ActionUrl: action_url} + + if m.BtnJsonList == nil { + m.BtnJsonList = []ActionCardMessageBtnList{} + } + + m.BtnJsonList = append(m.BtnJsonList, f) +} + +//OAMessage is the Message for OA +type OAMessage struct { + URL string `json:"message_url"` + PcURL string `json:"pc_message_url"` + Head struct { + BgColor string `json:"bgcolor,omitempty"` + Text string `json:"text,omitempty"` + } `json:"head,omitempty"` + Body struct { + Title string `json:"title,omitempty"` + Form []OAMessageForm `json:"form,omitempty"` + Rich OAMessageRich `json:"rich,omitempty"` + Content string `json:"content,omitempty"` + Image string `json:"image,omitempty"` + FileCount int `json:"file_count,omitempty"` + Author string `json:"author,omitempty"` + } `json:"body,omitempty"` +} + +type OAMessageForm struct { + Key string `json:"key,omitempty"` + Value string `json:"value,omitempty"` +} + +type OAMessageRich struct { + Num string `json:"num,omitempty"` + Unit string `json:"body,omitempty"` +} + +func (m *OAMessage) AppendFormItem(key string, value string) { + f := OAMessageForm{Key: key, Value: value} + + if m.Body.Form == nil { + m.Body.Form = []OAMessageForm{} + } + + m.Body.Form = append(m.Body.Form, f) +} + +//SendOAMessage is 发送OA消息 +func (c *DingTalkClient) SendOAMessage(sender string, cid string, msg OAMessage) (data MessageResponse, err error) { + request := map[string]interface{}{ + "chatid": cid, + "sender": sender, + "msgtype": "oa", + "oa": msg, + } + err = c.httpRPC("chat/send", nil, request, &data) + return data, err +} + +//GetMessageReadList is 获取已读列表 +func (c *DingTalkClient) GetMessageReadList(messageID string, cursor int, size int) (data MessageReadListResponse, err error) { + params := url.Values{} + params.Add("messageId", messageID) + params.Add("cursor", strconv.Itoa(cursor)) + params.Add("size", strconv.Itoa(size)) + err = c.httpRPC("chat/getReadList", params, nil, &data) + return data, err +} diff --git a/api_robot.go b/api_robot.go new file mode 100644 index 0000000..76845cb --- /dev/null +++ b/api_robot.go @@ -0,0 +1,75 @@ +package godingtalk + +import ( + "net/url" +) + +type RobotAtList struct { + AtMobiles []string `json:"atMobiles"` + IsAtAll bool `json:"isAtAll"` +} + +type RobotOutgoingMessage struct { + MessageType string `json:"msgtype"` + Text struct { + Content string `json:"content,omitempty"` + } `json:"text,omitempty"` + MessageID string `json:"msgId"` + CreatedAt int64 `json:"createAt"` + ConversationID string `json:"conversationId"` + ConversationType string `json:"conversationType"` + ConversationTitle string `json:"conversationTitle"` + SenderID string `json:"senderId"` + SenderNick string `json:"senderNick"` + SenderCorpID string `json:"senderCorpId"` + SenderStaffID string `json:"senderStaffId"` + ChatbotUserID string `json:"chatbotUserId"` + AtUsers []struct { + DingTalkID string `json:"dingtalkId,omitempty"` + StaffID string `json:"staffId,omitempty"` + } `json:"atUsers,omitempty"` +} + +//SendRobotTextMessage can send a text message to a group chat +func (c *DingTalkClient) SendRobotTextMessage(accessToken string, msg string) (data MessageResponse, err error) { + params := url.Values{} + params.Add("access_token", accessToken) + request := map[string]interface{}{ + "msgtype": "text", + "text": map[string]interface{}{ + "content": msg, + }, + } + err = c.httpRPC("robot/send", params, request, &data) + return data, err +} + +//SendRobotMarkdownMessage can send a text message to a group chat +func (c *DingTalkClient) SendRobotMarkdownMessage(accessToken string, title string, msg string) (data MessageResponse, err error) { + params := url.Values{} + params.Add("access_token", accessToken) + request := map[string]interface{}{ + "msgtype": "markdown", + "markdown": map[string]interface{}{ + "title": title, + "text": msg, + }, + } + err = c.httpRPC("robot/send", params, request, &data) + return data, err +} + +// SendRobotTextAtMessage can send a text message and at user to a group chat +func (c *DingTalkClient) SendRobotTextAtMessage(accessToken string, msg string, at *RobotAtList) (data OAPIResponse, err error) { + params := url.Values{} + params.Add("access_token", accessToken) + request := map[string]interface{}{ + "msgtype": "text", + "text": map[string]interface{}{ + "content": msg, + }, + "at": at, + } + err = c.httpRPC("robot/send", params, request, &data) + return data, err +} diff --git a/api_sns.go b/api_sns.go new file mode 100644 index 0000000..cb523b2 --- /dev/null +++ b/api_sns.go @@ -0,0 +1,44 @@ +//普通钉钉用户账号开放相关接口 +package godingtalk + +import ( + "net/url" + "strconv" + "time" +) + +type SnsUserInfoResponse struct { + OAPIResponse + + CorpInfo []struct { + CorpName string `json:"corp_name"` + IsAuth bool `json:"is_auth"` + IsManager bool `json:"is_manager"` + RightsLevel int `json:"rights_level"` + } `json:"corp_info"` + + UserInfo struct { + MaskedMobile string `json:"marskedMobile"` + Nick string `json:"nick"` + OpenID string `json:"openid"` + UnionID string `json:"unionid"` + DingID string `json:"dingId"` + } `json:"user_info"` +} + +//获取用户授权的个人信息 +func (c *DingTalkClient) SnsUserInfo(code string) (SnsUserInfoResponse, error) { + ts := strconv.FormatInt(time.Now().UnixNano()/1000000, 10) + params := url.Values{} + params.Add("accessKey", c.AppKey) + params.Add("timestamp", ts) + params.Add("signature", encodeSHA256(ts, c.AppSecret)) + + body := struct { + Code string `json:"tmp_auth_code"` + }{code} + + var data SnsUserInfoResponse + err := c.httpRequest("sns/getuserinfo_bycode", params, body, &data) + return data, err +} diff --git a/api_sns_test.go b/api_sns_test.go new file mode 100644 index 0000000..04793a4 --- /dev/null +++ b/api_sns_test.go @@ -0,0 +1,14 @@ +package godingtalk + +import ( + "fmt" + "testing" +) + +func TestSnsUserInfo(t *testing.T) { + userinfo, err := c.SnsUserInfo("f9f9ba22256136f29a7fb3dd5d26c24c") + if err != nil { + t.Fatal(err.Error()) + } + fmt.Println(userinfo) +} diff --git a/crypto.go b/crypto.go new file mode 100644 index 0000000..f7d9fb4 --- /dev/null +++ b/crypto.go @@ -0,0 +1,174 @@ +package godingtalk + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "errors" + "math/rand" + r "math/rand" + "sort" + "time" +) + +const ( + AES_ENCODE_KEY_LENGTH = 43 +) + +var DefaultDingtalkCrypto *Crypto + +type Crypto struct { + Token string + AesKey string + SuiteKey string + block cipher.Block + bkey []byte +} + +func encodeSHA256(body, secret string) string { + // 钉钉签名算法实现 + h := hmac.New(sha256.New, []byte(secret)) + h.Write([]byte(body)) + sum := h.Sum(nil) + return base64.StdEncoding.EncodeToString(sum) +} + +/* + token 数据签名需要用到的token,ISV(服务提供商)推荐使用注册套件时填写的token,普通企业可以随机填写 + aesKey 数据加密密钥。用于回调数据的加密,长度固定为43个字符,从a-z, A-Z, 0-9共62个字符中选取,您可以随机生成,ISV(服务提供商)推荐使用注册套件时填写的EncodingAESKey + suiteKey 一般使用corpID +*/ +func NewCrypto(token, aesKey, suiteKey string) (c *Crypto) { + c = &Crypto{ + Token: token, + AesKey: aesKey, + SuiteKey: suiteKey, + } + if len(c.AesKey) != AES_ENCODE_KEY_LENGTH { + panic("不合法的aeskey") + } + var err error + c.bkey, err = base64.StdEncoding.DecodeString(aesKey + "=") + if err != nil { + panic(err.Error()) + } + c.block, err = aes.NewCipher(c.bkey) + if err != nil { + panic(err.Error()) + } + return c +} + +/* + signature: 签名字符串 + timeStamp: 时间戳 + nonce: 随机字符串 + secretStr: 密文 + 返回: 解密后的明文 +*/ +func (c *Crypto) DecryptMsg(signature, timeStamp, nonce, secretStr string) (string, error) { + if !c.VerifySignature(c.Token, timeStamp, nonce, secretStr, signature) { + return "", errors.New("签名不匹配") + } + decode, err := base64.StdEncoding.DecodeString(secretStr) + if err != nil { + return "", err + } + if len(decode) < aes.BlockSize { + return "", errors.New("密文太短啦") + } + blockMode := cipher.NewCBCDecrypter(c.block, c.bkey[:c.block.BlockSize()]) + plantText := make([]byte, len(decode)) + blockMode.CryptBlocks(plantText, decode) + plantText = PKCS7UnPadding(plantText) + size := binary.BigEndian.Uint32(plantText[16 : 16+4]) + plantText = plantText[16+4:] + cropid := plantText[size:] + if string(cropid) != c.SuiteKey { + return "", errors.New("CropID不正确") + } + return string(plantText[:size]), nil +} + +func PKCS7UnPadding(plantText []byte) []byte { + length := len(plantText) + unpadding := int(plantText[length-1]) + return plantText[:(length - unpadding)] +} + +/* + replyMsg: 明文字符串 + timeStamp: 时间戳 + nonce: 随机字符串 + 返回: 密文,签名字符串 +*/ +func (c *Crypto) EncryptMsg(replyMsg, timeStamp, nonce string) (string, string, error) { + //原生消息体长度 + size := make([]byte, 4) + binary.BigEndian.PutUint32(size, uint32(len(replyMsg))) + replyMsg = c.RandomString(16) + string(size) + replyMsg + c.SuiteKey + plantText := PKCS7Padding([]byte(replyMsg), c.block.BlockSize()) + if len(plantText)%aes.BlockSize != 0 { + return "", "", errors.New("消息体大小不为16的倍数") + } + + blockMode := cipher.NewCBCEncrypter(c.block, c.bkey[:c.block.BlockSize()]) + ciphertext := make([]byte, len(plantText)) + blockMode.CryptBlocks(ciphertext, plantText) + outStr := base64.StdEncoding.EncodeToString(ciphertext) + sigStr := c.GenerateSignature(c.Token, timeStamp, nonce, string(outStr)) + return string(outStr), sigStr, nil +} + +func PKCS7Padding(ciphertext []byte, blockSize int) []byte { + padding := blockSize - len(ciphertext)%blockSize + padtext := bytes.Repeat([]byte{byte(padding)}, padding) + return append(ciphertext, padtext...) +} + +// 数据签名 +func (c *Crypto) GenerateSignature(token, timeStamp, nonce, secretStr string) string { + // 先将参数值进行排序 + params := make([]string, 0) + params = append(params, token) + params = append(params, secretStr) + params = append(params, timeStamp) + params = append(params, nonce) + sort.Strings(params) + return sha1Sign(params[0] + params[1] + params[2] + params[3]) +} + +// 校验数据签名 +func (c *Crypto) VerifySignature(token, timeStamp, nonce, secretStr, sigture string) bool { + return c.GenerateSignature(token, timeStamp, nonce, secretStr) == sigture +} + +func (c *Crypto) RandomString(n int, alphabets ...byte) string { + const alphanum = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + var bytes = make([]byte, n) + var randby bool + if num, err := rand.Read(bytes); num != n || err != nil { + r.Seed(time.Now().UnixNano()) + randby = true + } + for i, b := range bytes { + if len(alphabets) == 0 { + if randby { + bytes[i] = alphanum[r.Intn(len(alphanum))] + } else { + bytes[i] = alphanum[b%byte(len(alphanum))] + } + } else { + if randby { + bytes[i] = alphabets[r.Intn(len(alphabets))] + } else { + bytes[i] = alphabets[b%byte(len(alphabets))] + } + } + } + return string(bytes) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cd5bfbc --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module myschools.me/suguo/godingtalk + +go 1.13 + +require ( + github.com/google/go-github v17.0.0+incompatible + github.com/google/go-querystring v1.0.0 // indirect + github.com/ipandtcp/godingtalk v0.0.0-20180410032244-ca3d6ac197fb + golang.org/x/net v0.0.0-20190603091049-60506f45cf65 + golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 + google.golang.org/api v0.10.0 + google.golang.org/appengine v1.6.4 +) diff --git a/godingtalk.go b/godingtalk.go new file mode 100644 index 0000000..5990ab8 --- /dev/null +++ b/godingtalk.go @@ -0,0 +1,172 @@ +package godingtalk + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const ( + //VERSION is SDK version + VERSION = "0.3" +) + +//DingTalkClient is the Client to access DingTalk Open API +type DingTalkClient struct { + AppKey string + AppSecret string + + AgentID string + PartnerID string + AccessToken string + HTTPClient *http.Client + Cache Cache +} + +//Unmarshallable is +type Unmarshallable interface { + checkError() error + getWriter() io.Writer +} + +//OAPIResponse is +type OAPIResponse struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` +} + +func (data *OAPIResponse) checkError() (err error) { + if data.ErrCode != 0 { + err = fmt.Errorf("%d: %s", data.ErrCode, data.ErrMsg) + } + return err +} + +func (data *OAPIResponse) getWriter() io.Writer { + return nil +} + +//MessageResponse is +type MessageResponse struct { + OAPIResponse + MessageID string `json:"messageId"` +} + +//MessageResponse is +type MessageReadListResponse struct { + OAPIResponse + NextCursor int64 `json:"next_cursor"` + ReadUserIdList []string `json:"readUserIdList"` +} + +//AccessTokenResponse is +type AccessTokenResponse struct { + OAPIResponse + AccessToken string `json:"access_token"` + Expires int `json:"expires_in"` + Created int64 +} + +//CreatedAt is when the access token is generated +func (e *AccessTokenResponse) CreatedAt() int64 { + return e.Created +} + +//ExpiresIn is how soon the access token is expired +func (e *AccessTokenResponse) ExpiresIn() int { + return e.Expires +} + +//JsAPITicketResponse is +type JsAPITicketResponse struct { + OAPIResponse + Ticket string + Expires int `json:"expires_in"` + Created int64 +} + +//CreatedAt is when the ticket is generated +func (e *JsAPITicketResponse) CreatedAt() int64 { + return e.Created +} + +//ExpiresIn is how soon the ticket is expired +func (e *JsAPITicketResponse) ExpiresIn() int { + return e.Expires +} + +//NewDingTalkClient creates a DingTalkClient instance +func NewDingTalkClient(appkey, appsecret string) *DingTalkClient { + c := new(DingTalkClient) + c.AppKey = appkey + c.AppSecret = appsecret + c.HTTPClient = &http.Client{ + Timeout: 30 * time.Second, + } + c.Cache = NewFileCache(".auth_file") + return c +} + +//RefreshAccessToken is to get a valid access token +func (c *DingTalkClient) RefreshAccessToken() error { + var data AccessTokenResponse + err := c.Cache.Get(&data) + if err == nil { + c.AccessToken = data.AccessToken + return nil + } + + params := url.Values{} + params.Add("appkey", c.AppKey) + params.Add("appsecret", c.AppSecret) + + err = c.httpRPC("gettoken", params, nil, &data) + if err == nil { + c.AccessToken = data.AccessToken + data.Expires = data.Expires | 7200 + data.Created = time.Now().Unix() + err = c.Cache.Set(&data) + } + return err +} + +//GetJsAPITicket is to get a valid ticket for JS API +func (c *DingTalkClient) GetJsAPITicket() (ticket string, err error) { + var data JsAPITicketResponse + cache := NewFileCache(".jsapi_ticket") + err = cache.Get(&data) + if err == nil { + return data.Ticket, err + } + err = c.httpRPC("get_jsapi_ticket", nil, nil, &data) + if err == nil { + ticket = data.Ticket + cache.Set(&data) + } + return ticket, err +} + +//GetConfig is to return config in json +func (c *DingTalkClient) GetConfig(nonceStr string, timestamp string, url string) string { + ticket, _ := c.GetJsAPITicket() + config := map[string]string{ + "url": url, + "nonceStr": nonceStr, + "agentId": c.AgentID, + "timeStamp": timestamp, + "corpId": c.AppKey, + "ticket": ticket, + "signature": Sign(ticket, nonceStr, timestamp, url), + } + bytes, _ := json.Marshal(&config) + return string(bytes) +} + +//Sign is 签名 +func Sign(ticket string, nonceStr string, timeStamp string, url string) string { + s := fmt.Sprintf("jsapi_ticket=%s&noncestr=%s×tamp=%s&url=%s", ticket, nonceStr, timeStamp, url) + return sha1Sign(s) +} diff --git a/godingtalk_test.go b/godingtalk_test.go new file mode 100644 index 0000000..b33249c --- /dev/null +++ b/godingtalk_test.go @@ -0,0 +1,186 @@ +package godingtalk + +import ( + "os" + "testing" + "time" +) + +var c *DingTalkClient + +func init() { + c = NewDingTalkClient("dinguipztmzpv8sog933", "kUYbKHxNixdMhTmW6IrdTE-yVnWfQLs1C7RQIAsrlwz8BYlVmceFs-3JRBmU32rQ") + err := c.RefreshAccessToken() + if err != nil { + panic(err) + } +} + +func TestInitWithAppKey(t *testing.T) { + c1 := NewDingTalkClient("dinguipztmzpv8sog933", "kUYbKHxNixdMhTmW6IrdTE-yVnWfQLs1C7RQIAsrlwz8BYlVmceFs-3JRBmU32rQ") + err := c1.RefreshAccessToken() + if err != nil { + panic(err) + } + _, err = c1.SendRobotTextMessage(c1.AccessToken, "Message sent successfully with appkey and appsecret") + if err != nil { + t.Error(err) + } +} + +func TestCalendarListApi(t *testing.T) { + from := time.Now().AddDate(0, 0, -1) + to := time.Now().AddDate(0, 0, 1) + _, err := c.ListEvents("0420506555", from, to) + if err != nil { + panic(err) + } + //for _, event := range events { + // t.Logf("%v %v %v %v", event.Start, event.End, event.Summary, event.Description) + //} +} + +func TestDepartmentApi(t *testing.T) { + departments, err := c.DepartmentList() + // t.Logf("%+v %+v", departments, err) + if err != nil { + t.Error(err) + t.FailNow() + } + d, err := c.DepartmentDetail(departments.Departments[0].Id) + if err != nil { + t.Error(err) + t.FailNow() + } + if d.Id != departments.Departments[0].Id { + t.Error("DepartmentDetail error") + } + + for _, department := range departments.Departments { + t.Logf("dept: %v", department) + list, err := c.UserList(department.Id, 0, 100) + if err != nil { + t.Error(err) + } + for _, user := range list.Userlist { + t.Logf("\t\tuser: %v", user) + } + } +} + +func TestJsAPITicket(t *testing.T) { + ticket, err := c.GetJsAPITicket() + if err != nil || ticket == "" { + t.Error("JsAPITicket error", err) + } +} + +func TestCreateChat(t *testing.T) { + // chatid, err := c.CreateChat("Test chat", "0420506555", []string{"0420506555"}) + // if err!=nil { + // t.Error(err) + // } + // t.Log("-----",chatid) +} + +func TestSendAppMessageApi(t *testing.T) { + err := c.SendAppMessage("22194403", "0420506555", "测试消息,请忽略") //@all + if err != nil { + t.Error(err) + } +} + +func TestTextMessage(t *testing.T) { + data, err := c.SendTextMessage("011217462940", "chat6a93bc1ee3b7d660d372b1b877a9de62", "测试消息,来自双十一,请忽略") + if err != nil { + t.Error(err) + } else { + if data.MessageID == "" { + t.Error("Message id is empty") + } + } + data2, _ := c.GetMessageReadList(data.MessageID, 0, 10) + if len(data2.ReadUserIdList) == 0 { + t.Error("Message Read List should not be empty") + } +} + +func TestSendOAMessage(t *testing.T) { + msg := OAMessage{} + msg.URL = "http://www.google.com/" + msg.Head.Text = "头部标题" + msg.Head.BgColor = "FFBBBBBB" + msg.Body.Title = "正文标题" + msg.Body.Content = "test content" + _, err := c.SendOAMessage("011217462940", "chat6a93bc1ee3b7d660d372b1b877a9de62", msg) + if err != nil { + t.Error(err) + } +} + +func _TestDownloadAndUploadImage(t *testing.T) { + f, err := os.Create("lADOHrf_oVxc.jpg") + if err == nil { + err = c.DownloadMedia("@lADOHrf_oVxc", f) + } + if err != nil { + t.Error(err) + } + f.Close() + + f, _ = os.Open("lADOHrf_oVxc.jpg") + defer f.Close() + media, err := c.UploadMedia("image", "myfile.jpg", f) + if media.MediaID == "" { + t.Error("Upload File Failed") + } + t.Log("uploaded file mediaid:", media.MediaID) + if err != nil { + t.Error(err) + } + _, err = c.SendImageMessage("011217462940", "chat6a93bc1ee3b7d660d372b1b877a9de62", "@lADOHrf_oVxc") + if err != nil { + t.Error(err) + } +} + +func TestVoiceMessage(t *testing.T) { + // f, _ := os.Open("/Users/hugozhu/Downloads/BlackBerry_test2_AMR-NB_Mono_12.2kbps_8000Hz.amr") + // defer f.Close() + // media, err := c.UploadMedia("voice", "sample.amr", f) + // if media.MediaID == "" { + // t.Error("Upload File Failed") + // } + // t.Log("uploaded file mediaid:", media.MediaID) + // if err != nil { + // t.Error(err) + // } + _, err := c.SendVoiceMessage("011217462940", "chat6a93bc1ee3b7d660d372b1b877a9de62", "@lATOHr53E84DALnDzml4wS0", "10") + if err != nil { + t.Error(err) + } +} + +func TestRobotMessage(t *testing.T) { + _, err := c.SendRobotTextMessage(os.Getenv("token"), "这是一条测试消息") + if err != nil { + t.Error(err) + } + + _, err = c.SendRobotMarkdownMessage(os.Getenv("token"), "测试标题", "# 杭州天气\n这是一条测试消息\n"+ + "> 9度,西北风1级,空气良89,**相对温度**73%\n\n"+ + "> ![screenshot](https://gw.alicdn.com/tfs/TB1ut3xxbsrBKNjSZFpXXcXhFXa-846-786.png)\n"+ + "> ###### 10点20分发布 [天气](http://www.thinkpage.cn/) \n") + if err != nil { + t.Error(err) + } +} + +func TestRobotAtMessage(t *testing.T) { + _, err := c.SendRobotTextAtMessage(os.Getenv("token"), "这是一条测试消息", &RobotAtList{ + IsAtAll: true, + }) + if err != nil { + t.Error(err) + } +} diff --git a/top_api_approval.go b/top_api_approval.go new file mode 100644 index 0000000..6d101cb --- /dev/null +++ b/top_api_approval.go @@ -0,0 +1,187 @@ +/* + * Author Kevin Zhu + * + * Direct questions, comments to + */ + +package godingtalk + +import ( + "encoding/json" + "errors" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + topAPICreateProcInstMethod = "dingtalk.smartwork.bpms.processinstance.create" + topAPIGetProcInstMethod = "dingtalk.smartwork.bpms.processinstance.get" + topAPIListProcInstMethod = "dingtalk.smartwork.bpms.processinstance.list" +) + +type TopAPICreateProcInst struct { + // 审批模板code + ProcessCode string `json:"process_code"` + // 发起人UID + OriginatorUID string `json:"originator_user_id"` + // 发起人所在部门 + DeptID int `json:"dept_id"` + // 审批人列表 + Approvers []string `json:"approvers"` + // 抄送人列表 + CCList []string `json:"cc_list"` + //抄送时间,分为(START,FINISH,START_FINISH + CCPosition string `json:"cc_position"` + // 审批单内容, Name为审批模板中的列名, value 为该列的值 + FormCompntValues []ProcInstCompntValues `json:"form_component_values"` +} + +type ProcInst struct { + ProcInstID string `json:"process_instance_id"` + Title string `json:"title"` + CreateTime string `json:"create_time"` + FinishTime string `json:"finish_time"` + OriginatorUID string `json:"originator_userid"` + Status string `json:"status"` + ApproverUIDS []string `json:"approver_userids"` + CCUIDS []string `json:"cc_userids"` + Result string `json:"result"` + BusinessID string `json:"business_id"` + FormCompntValues []ProcInstCompntValues `json:"form_component_values"` // 表单详情列表 + Tasks []_ProcInstTasks `json:"tasks"` // 任务列表 + OperationRecords []_ProcInstOperationRecords `json:"operation_records"` // 操作记录列表 + OriginatorDeptID string `json:"originator_dept_id"` + OriginatorDeptName string `json:"originator_dept_name"` +} + +type ProcInstCompntValues struct { + Name string `json:"name"` + Value string `json:"value"` + ExtValue string `json:"ext_value"` +} + +type _ProcInstOperationRecords struct { + UID string `json:"userid"` + Date string `json:"date"` + Type string `json:"operation_type"` + Result string `json:"operation_result"` + Remark string `json:"remark"` +} + +type _ProcInstTasks struct { + UID string `json:"userid"` + Status string `json:"task_status"` + Result string `json:"task_result"` + CreateTime string `json:"create_time"` + FinishTime string `json:"finish_time"` +} + +type topAPICreateProcInstResp struct { + topAPIErrResponse + OK struct { + Errcode int `json:"ding_open_errcode"` + ErrMsg string `json:"error_msg"` + IsSuccess bool `jons:"is_success"` + ProcInstID string `json:"process_instance_id"` + } `json:"result"` + RequestID string `json:"request_id"` +} + +// 发起审批 +func (c *DingTalkClient) TopAPICreateProcInst(data TopAPICreateProcInst) (string, error) { + var resp topAPICreateProcInstResp + values, err := json.Marshal(data.FormCompntValues) + if err != nil { + return "", err + } + + form := url.Values{} + form.Add("method", topAPICreateProcInstMethod) + form.Add("cc_list", strings.Join(data.CCList, ",")) + form.Add("dept_id", strconv.Itoa(data.DeptID)) + form.Add("approvers", strings.Join(data.Approvers, ",")) + form.Add("cc_position", data.CCPosition) + form.Add("process_code", data.ProcessCode) + form.Add("originator_user_id", data.OriginatorUID) + form.Add("form_component_values", string(values)) + if c.AgentID != "" { + form.Add("agent_id", c.AgentID) + } + + return resp.OK.ProcInstID, c.topAPIRequest(form, &resp) +} + +type topAPIGetProcInstResp struct { + Ok struct { + ErrCode int `json:"ding_open_errcode"` + ErrMsg string `json:"error_msg"` + Success bool `json:"success"` + ProcInst ProcInst `json:"process_instance"` + } `json:"result"` + RequestID string `json:"request_id"` + topAPIErrResponse +} + +// 根据审批实例id获取单条审批实例详情 +func (c *DingTalkClient) TopAPIGetProcInst(pid string) (ProcInst, error) { + var resp topAPIGetProcInstResp + reqForm := url.Values{} + reqForm.Add("process_instance_id", pid) + reqForm.Add("method", topAPIGetProcInstMethod) + err := c.topAPIRequest(reqForm, &resp) + if err != nil { + return resp.Ok.ProcInst, err + } + resp.Ok.ProcInst.ProcInstID = pid + return resp.Ok.ProcInst, err +} + +type ListProcInst struct { + ApproverUIDS []string `json:"approver_userid_list"` + CCUIDS []string `json:"cc_userid_list"` + FormCompntValues []ProcInstCompntValues `json:"form_component_values"` + ProcInstID string `json:"process_instance_id"` + Title string `json:"title"` + CreateTime string `json:"create_time"` + FinishTime string `json:"finish_time"` + OriginatorUID string `json:"originator_userid"` + Status string `json:"status"` + BusinessID string `json:"business_id"` + OriginatorDeptID string `json:"originator_dept_id"` + ProcInstResult string `json:"process_instance_result"` // "agree", +} + +type TopAPIListProcInstResp struct { + topAPIErrResponse + OK struct { + ErrCode int `json:"ding_open_errcode"` + ErrMsg string `json:"error_msg"` + Success bool `json:"success"` + Result struct { + List []ListProcInst `json:"list"` + NextCursor int `json:"next_cursor"` + } `json:"result"` + } `json:"result"` + RequestID string `json:"request_id"` +} + +// 获取审批实例列表 +// Note: processCode 官方不会检查错误,请保证processCode正确 +func (c *DingTalkClient) TopAPIListProcInst(processCode string, startTime, endTime time.Time, size, cursor int, useridList []string) (TopAPIListProcInstResp, error) { + var resp TopAPIListProcInstResp + if size > 10 { + return resp, errors.New("Max size is 10") + } + + reqForm := url.Values{} + reqForm.Add("process_code", processCode) + reqForm.Add("start_time", strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10)) + reqForm.Add("end_time", strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10)) + reqForm.Add("size", strconv.Itoa(size)) + reqForm.Add("cursor", strconv.Itoa(cursor)) + reqForm.Add("userid_list", strings.Join(useridList, ",")) + reqForm.Add("method", topAPIListProcInstMethod) + return resp, c.topAPIRequest(reqForm, &resp) +} diff --git a/top_api_approval_test.go b/top_api_approval_test.go new file mode 100644 index 0000000..11eecc8 --- /dev/null +++ b/top_api_approval_test.go @@ -0,0 +1,46 @@ +package godingtalk + +import ( + "encoding/json" + "testing" + "time" +) + +func TestTopAPIProcInst(t *testing.T) { + var compntValues []ProcInstCompntValues + compntValues = append(compntValues, ProcInstCompntValues{Name: "单行输入框", Value: "单行输入框输入的内容"}) + + detailCompntValues := [][]ProcInstCompntValues{[]ProcInstCompntValues{ProcInstCompntValues{Name: "明细内单行输入框", Value: "明细内单行输入框的内容"}}} + detailValues, _ := json.Marshal(detailCompntValues) + compntValues = append(compntValues, ProcInstCompntValues{Name: "明细1", Value: string(detailValues)}) + + procInstData := TopAPICreateProcInst{ + Approvers: []string{"085354234826136236"}, + CCList: []string{"085354234826136236"}, + CCPosition: "START", + DeptID: 4207088, + OriginatorUID: "085354234826136236", + ProcessCode: "PROC-FF6YHQ9WQ2-RWDT8XCUTV0U5IAT7JBM1-8MD0TNEJ-6", + FormCompntValues: compntValues, + } + procInstID, err := c.TopAPICreateProcInst(procInstData) + if err != nil { + t.Log(err) + } else { + t.Logf("%+v\n", procInstID) + } + + procInst, err := c.TopAPIGetProcInst(procInstID) + if err != nil { + t.Log(err) + } else { + t.Logf("%+v\n", procInst) + } + + listResp, err := c.TopAPIListProcInst("PROC-FF6YHQ9WQ2-RWDT8XCUTV0U5IAT7JBM1-8MD0TNEJ-6", time.Now().AddDate(0, 0, -10), time.Now(), 10, 0, nil) + if err != nil { + t.Log(err) + } else { + t.Logf("%+v\n", listResp) + } +} diff --git a/top_api_message.go b/top_api_message.go new file mode 100644 index 0000000..1878845 --- /dev/null +++ b/top_api_message.go @@ -0,0 +1,124 @@ +/* + * Author Kevin Zhu + * + * Direct questions, comments to + */ + +package godingtalk + +import ( + "encoding/json" + "errors" + "net/url" + "strconv" + "strings" +) + +const ( + topAPIMsgAsyncSendMethod = "dingtalk.corp.message.corpconversation.asyncsend" + topAPIMsgGetResultMethod = "dingtalk.corp.message.corpconversation.getsendresult" + topAPIMsgGetprogressMethod = "dingtalk.corp.message.corpconversation.getsendprogress" +) + +type topAPIMsgSendResponse struct { + topAPIErrResponse + OK struct { + ErrCode int `json:"ding_open_errcode"` + ErrMsg string `json:"error_msg"` + Success bool `json:"success"` + TaskID int `json:"task_id"` + } `json:"result"` +} + +// mgType 消息类型:text;iamge;voice;file;link;oa;markdown;action_card +// userList 接收推送的UID 列表 +// deptList 接收推送的部门ID列表 +// toAll 是否发送给所有用户 +// msgContent 消息内容 +// If success return task_id, or is error is not nil when errored +func (c *DingTalkClient) TopAPIMsgSend(msgType string, userList []string, deptList []int, toAll bool, msgContent interface{}) (int, error) { + var resp topAPIMsgSendResponse + if len(userList) > 20 || len(deptList) > 20 { + return 0, errors.New("Can't more than 20 users or departments at once") + } + + mcontent, err := json.Marshal(msgContent) + if err != nil { + return 0, err + } + + toAllStr := "false" + if toAll { + toAllStr = "true" + } + + form := url.Values{ + "method": {topAPIMsgAsyncSendMethod}, + "agent_id": {c.AgentID}, + "userid_list": {strings.Join(userList, ",")}, + "to_all_user": {toAllStr}, + "msgtype": {msgType}, + "msgcontent": {string(mcontent)}, + } + + if len(deptList) > 0 { + var deptListStr string + for _, dept := range deptList { + deptListStr = strconv.Itoa(dept) + "," + } + deptListStr = string([]uint8(deptListStr)[0 : len(deptListStr)-1]) + form.Set("dept_id_list", deptListStr) + } + + return resp.OK.TaskID, c.topAPIRequest(form, &resp) +} + +type TopAPIMsgGetSendResult struct { + topAPIErrResponse + OK struct { + ErrCode int `json:"ding_open_errcode"` + ErrMsg string `json:"error_msg"` + Success bool `json:"success"` + SendResult struct { + InvalidUserIDList []string `json:"invalid_user_id_list"` + ForbiddenUserIDList []string `json:"forbidden_user_id_list"` + FaildedUserIDList []string `json:"failed_user_id_list"` + ReadUserIDLIst []string `json:"read_user_id_list"` + UnreadUserIDList []string `json:"unread_user_id_list"` + InvalidDeptIDList []int `json:"invalid_dept_id_list"` + } `json:"send_result"` + } `json:"result"` +} + +func (c *DingTalkClient) TopAPIMsgGetSendResult(taskID int) (TopAPIMsgGetSendResult, error) { + var resp TopAPIMsgGetSendResult + form := url.Values{ + "method": {topAPIMsgGetResultMethod}, + "agent_id": {c.AgentID}, + "task_id": {strconv.Itoa(taskID)}, + } + return resp, c.topAPIRequest(form, &resp) +} + +type TopAPIMsgGetSendProgress struct { + topAPIErrResponse + OK struct { + ErrCode int `json:"ding_open_errcode"` + ErrMsg string `json:"error_msg"` + Success bool `json:"success"` + Progress struct { + Percent int `json:"progress_in_percent"` + Status int `json:"status"` + } `json:"progress"` + } `json:"result"` +} + +func (c *DingTalkClient) TopAPIMsgGetSendProgress(taskID int) (TopAPIMsgGetSendProgress, error) { + var resp TopAPIMsgGetSendProgress + form := url.Values{ + "method": {topAPIMsgGetprogressMethod}, + "agent_id": {c.AgentID}, + "task_id": {strconv.Itoa(taskID)}, + } + return resp, c.topAPIRequest(form, &resp) +} diff --git a/top_api_message_test.go b/top_api_message_test.go new file mode 100644 index 0000000..f901d87 --- /dev/null +++ b/top_api_message_test.go @@ -0,0 +1,35 @@ +package godingtalk + +import ( + "testing" +) + +func TestTopAPImsg(t *testing.T) { + c.AgentID = "161271936" + msg := OAMessage{} + msg.URL = "http://www.google.com/" + msg.Head.Text = "头部标题" + msg.Head.BgColor = "FFBBBBBB" + msg.Body.Title = "正文标题" + msg.Body.Content = "test content" + taskID, err := c.TopAPIMsgSend("oa", []string{"085354234826136236"}, nil, false, msg) + if err != nil { + t.Error(err) + } else { + t.Logf("%d\n", taskID) + } + + sendProgress, err := c.TopAPIMsgGetSendProgress(taskID) + if err != nil { + t.Error(err) + } else { + t.Logf("%v\n", sendProgress.OK.Progress) + } + + sendResult, err := c.TopAPIMsgGetSendResult(taskID) + if err != nil { + t.Error(err) + } else { + t.Logf("%v\n", sendResult.OK.SendResult) + } +} diff --git a/top_api_request.go b/top_api_request.go new file mode 100644 index 0000000..40e80e1 --- /dev/null +++ b/top_api_request.go @@ -0,0 +1,88 @@ +/* + * Author Kevin Zhu + * + * Direct questions, comments to + */ + +package godingtalk + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "time" +) + +const ( + topAPIRootURL = "https://eco.taobao.com/router/rest" + formDataType = "application/x-www-form-urlencoded;charset=utf-8" +) + +type TopAPIResponse interface { + checkError() error +} + +type topAPIErrResponse struct { + ERR struct { + Code int `json:"code"` + Msg string `json:"msg"` + SubCode string `json:"sub_code"` + SubMsg string `json:"sub_msg"` + RequestID string `json:"request_id"` + } `json:"error_response"` +} + +func (data *topAPIErrResponse) checkError() (err error) { + if data.ERR.Code != 0 || len(data.ERR.SubCode) != 0 { + err = fmt.Errorf("%#v", data.ERR) + } + return err +} + +func (c *DingTalkClient) topAPIRequest(requestForm url.Values, respData TopAPIResponse) error { + requestForm.Set("v", "2.0") + requestForm.Set("format", "json") + requestForm.Set("simplify", "true") + + err := c.RefreshAccessToken() + if err != nil { + return err + } + requestForm.Set("session", c.AccessToken) + if requestForm.Get("timestamp") == "" { + requestForm.Set("timestamp", time.Now().Format("2006-01-02 15:04:05")) + } + if c.PartnerID != "" { + requestForm.Set("partner_id", c.PartnerID) + } + + v := bytes.NewBuffer([]byte(requestForm.Encode())) + + req, _ := http.NewRequest("POST", topAPIRootURL, v) + req.Header.Set("Content-Type", formDataType) + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + + if resp.StatusCode != 200 { + return errors.New("Server error: " + resp.Status) + } + + defer resp.Body.Close() + buf, err := ioutil.ReadAll(resp.Body) + + if err == nil { + err := json.Unmarshal(buf, &respData) + if err != nil { + return err + } + return respData.checkError() + } + return err +} diff --git a/transport.go b/transport.go new file mode 100644 index 0000000..afbc594 --- /dev/null +++ b/transport.go @@ -0,0 +1,119 @@ +package godingtalk + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "io/ioutil" + "log" + "mime/multipart" + "net/http" + "net/url" + "os" +) + +const typeJSON = "application/json" + +//UploadFile is for uploading a single file to DingTalk +type UploadFile struct { + FieldName string + FileName string + Reader io.Reader +} + +//DownloadFile is for downloading a single file from DingTalk +type DownloadFile struct { + MediaID string + FileName string + Reader io.Reader +} + +func (c *DingTalkClient) httpRPC(path string, params url.Values, requestData interface{}, responseData Unmarshallable) error { + if c.AccessToken != "" { + if params == nil { + params = url.Values{} + } + if params.Get("access_token") == "" { + params.Set("access_token", c.AccessToken) + } + } + return c.httpRequest(path, params, requestData, responseData) +} + +func (c *DingTalkClient) httpRequest(path string, params url.Values, requestData interface{}, responseData Unmarshallable) error { + client := c.HTTPClient + var request *http.Request + ROOT := os.Getenv("oapi_server") + if ROOT == "" { + ROOT = "oapi.dingtalk.com" + } + DEBUG := os.Getenv("debug") != "" + url2 := "https://" + ROOT + "/" + path + "?" + params.Encode() + // log.Println(url2) + if requestData != nil { + switch requestData.(type) { + case UploadFile: + var b bytes.Buffer + w := multipart.NewWriter(&b) + + uploadFile := requestData.(UploadFile) + if uploadFile.Reader == nil { + return errors.New("upload file is empty") + } + fw, err := w.CreateFormFile(uploadFile.FieldName, uploadFile.FileName) + if err != nil { + return err + } + if _, err = io.Copy(fw, uploadFile.Reader); err != nil { + return err + } + if err = w.Close(); err != nil { + return err + } + request, _ = http.NewRequest("POST", url2, &b) + request.Header.Set("Content-Type", w.FormDataContentType()) + default: + d, _ := json.Marshal(requestData) + if DEBUG { + log.Printf("url: %s request: %s", url2, string(d)) + } + request, _ = http.NewRequest("POST", url2, bytes.NewReader(d)) + request.Header.Set("Content-Type", typeJSON) + } + } else { + if DEBUG { + log.Printf("url: %s", url2) + } + request, _ = http.NewRequest("GET", url2, nil) + } + resp, err := client.Do(request) + if err != nil { + return err + } + + if resp.StatusCode != 200 { + return errors.New("Server error: " + resp.Status) + } + + defer resp.Body.Close() + contentType := resp.Header.Get("Content-Type") + if DEBUG { + log.Printf("url: %s response content type: %s", url2, contentType) + } + pos := len(typeJSON) + if len(contentType) >= pos && contentType[0:pos] == typeJSON { + content, err := ioutil.ReadAll(resp.Body) + if DEBUG { + log.Println(string(content)) + } + if err == nil { + json.Unmarshal(content, responseData) + return responseData.checkError() + } + } else { + io.Copy(responseData.getWriter(), resp.Body) + return responseData.checkError() + } + return err +} diff --git a/util.go b/util.go new file mode 100644 index 0000000..62523ca --- /dev/null +++ b/util.go @@ -0,0 +1,102 @@ +package godingtalk + +import ( + "crypto/sha1" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "time" +) + +type Expirable interface { + CreatedAt() int64 + ExpiresIn() int +} + +type Cache interface { + Set(data Expirable) error + Get(data Expirable) error +} + +type FileCache struct { + Path string +} + +func NewFileCache(path string) *FileCache { + return &FileCache{ + Path: path, + } +} + +func (c *FileCache) Set(data Expirable) error { + bytes, err := json.Marshal(data) + if err == nil { + ioutil.WriteFile(c.Path, bytes, 0644) + } + return err +} + +func (c *FileCache) Get(data Expirable) error { + bytes, err := ioutil.ReadFile(c.Path) + if err == nil { + err = json.Unmarshal(bytes, data) + if err == nil { + created := data.CreatedAt() + expires := data.ExpiresIn() + if err == nil && time.Now().Unix() > created+int64(expires-60) { + err = errors.New("Data is already expired") + } + } + } + return err +} + +type InMemoryCache struct { + data []byte +} + +func NewInMemoryCache() *InMemoryCache { + return &InMemoryCache{} +} + +func (c *InMemoryCache) Set(data Expirable) error { + bytes, err := json.Marshal(data) + if err == nil { + c.data = bytes + } + return err +} + +func (c *InMemoryCache) Get(data Expirable) error { + err := json.Unmarshal(c.data, data) + if err == nil { + created := data.CreatedAt() + expires := data.ExpiresIn() + if err == nil && time.Now().Unix() > created+int64(expires-60) { + err = errors.New("Data is already expired") + } + } + return err +} + +func sha1Sign(s string) string { + // The pattern for generating a hash is `sha1.New()`, + // `sha1.Write(bytes)`, then `sha1.Sum([]byte{})`. + // Here we start with a new hash. + h := sha1.New() + + // `Write` expects bytes. If you have a string `s`, + // use `[]byte(s)` to coerce it to bytes. + h.Write([]byte(s)) + + // This gets the finalized hash result as a byte + // slice. The argument to `Sum` can be used to append + // to an existing byte slice: it usually isn't needed. + bs := h.Sum(nil) + + // SHA1 values are often printed in hex, for example + // in git commits. Use the `%x` format verb to convert + // a hash results to a hex string. + return fmt.Sprintf("%x", bs) +} diff --git a/util_test.go b/util_test.go new file mode 100644 index 0000000..0ae28fe --- /dev/null +++ b/util_test.go @@ -0,0 +1,78 @@ +package godingtalk + +import "testing" +import "time" + +type ExpiresData struct { + Data string + Expires int `json:"expires_in"` + Created int64 `json:"created"` +} + +func (e *ExpiresData) CreatedAt() int64 { + return e.Created +} + +func (e *ExpiresData) ExpiresIn() int { + return e.Expires +} + +func TestFileCache(t *testing.T) { + cache := NewFileCache(".test_cache") + data := ExpiresData{ + Data: "Hello World!", + Expires: 7200, + Created: time.Now().Unix(), + } + cache.Set(&data) + + var data2 ExpiresData + cache.Get(&data2) + t.Logf("%+v %+v", data, data2) + + if data2.Created != data.Created { + t.Errorf("FileCache error") + } + + data = ExpiresData{ + Data: "Hello World 2!", + Expires: 0, + Created: time.Now().Unix(), + } + cache.Set(&data) + err := cache.Get(&data2) + if err == nil { + t.Error("FileCache error: err should not be nil") + } + t.Logf("%+v %+v", data, data2) +} + +func TestInMemoryCache(t *testing.T) { + cache := NewInMemoryCache() + data := ExpiresData{ + Data: "Hello World!", + Expires: 7200, + Created: time.Now().Unix(), + } + cache.Set(&data) + + var data2 ExpiresData + cache.Get(&data2) + t.Logf("%+v %+v", data, data2) + + if data2.Created != data.Created { + t.Errorf("InMemoryCache error") + } + + data = ExpiresData{ + Data: "Hello World 2!", + Expires: 0, + Created: time.Now().Unix(), + } + cache.Set(&data) + err := cache.Get(&data2) + if err == nil { + t.Error("InMemoryCache error: err should not be nil") + } + t.Logf("%+v %+v", data, data2) +}