diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..038e99c --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,25 @@ +# This workflow will build a golang project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go + +name: Go + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.22.1' + + - name: Test + run: go test -v ./... diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2f52490 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 toanppp + +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..7d9d756 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# zgo + +Zalo Go APIs + +## To do + +- [x] [Authenticate](https://developers.zalo.me/docs/official-account/bat-dau/xac-thuc-va-uy-quyen-cho-ung-dung-new) +- [ ] [Message](https://developers.zalo.me/docs/official-account/tin-nhan/tong-quan) + - [x] Get messages in a conversation +- [ ] [Webhook](https://developers.zalo.me/docs/official-account/webhook/tong-quan) + - [x] Calculate signature + +## Install + +```sh +go get github.com/toanppp/zgo +``` diff --git a/authenticate.go b/authenticate.go new file mode 100644 index 0000000..225f8dd --- /dev/null +++ b/authenticate.go @@ -0,0 +1,60 @@ +package zgo + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" +) + +type GetAccessTokenResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn string `json:"expires_in"` + ErrorResp +} + +func (z *zgo) RefreshAccessToken(ctx context.Context, refreshToken string) (GetAccessTokenResp, error) { + payload := url.Values{} + payload.Set("app_id", z.appID) + payload.Set("grant_type", "refresh_token") + payload.Set("refresh_token", refreshToken) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, getAccessTokenURL, bytes.NewBufferString(payload.Encode())) + if err != nil { + return GetAccessTokenResp{}, err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("secret_key", z.appSecret) + + resp, err := z.httpClient.Do(req) + if err != nil { + return GetAccessTokenResp{}, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return GetAccessTokenResp{}, errors.New("request failed") + } + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return GetAccessTokenResp{}, err + } + + var data GetAccessTokenResp + if err := json.Unmarshal(responseBody, &data); err != nil { + return GetAccessTokenResp{}, err + } + + if data.Error != 0 { + return data, fmt.Errorf("request failed: %+v", data) + } + + return data, nil +} diff --git a/error.go b/error.go new file mode 100644 index 0000000..8032593 --- /dev/null +++ b/error.go @@ -0,0 +1,16 @@ +package zgo + +const ( + TooManyRequest = -32 + InvalidAccessToken = -216 + InvalidRefreshToken = -14014 + ExpiredRefreshToken = -14020 +) + +type ErrorResp struct { + ErrorName string `json:"error_name"` + ErrorReason string `json:"error_reason"` + RefDoc string `json:"ref_doc"` + ErrorDescription string `json:"error_description"` + Error int `json:"error"` +} diff --git a/event.go b/event.go new file mode 100644 index 0000000..648f45f --- /dev/null +++ b/event.go @@ -0,0 +1,35 @@ +package zgo + +import ( + "strconv" + "time" +) + +const ( + AddUserToTag = "add_user_to_tag" + RemoveUserFromTag = "remove_user_from_tag" + + TagFinished = "Hoàn thành" +) + +type Event struct { + OAID string `json:"oa_id"` + UserIDByApp string `json:"user_id_by_app"` + EventName string `json:"event_name"` + Tag UserTag `json:"tag"` + AppID string `json:"app_id"` + Timestamp string `json:"timestamp"` +} + +func (e Event) CreatedAt() time.Time { + msec, err := strconv.ParseInt(e.Timestamp, 10, 64) + if err != nil { + return time.Time{} + } + return time.UnixMilli(msec) +} + +type UserTag struct { + UserIDs []string `json:"user_ids"` + Name string `json:"name"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..057c7a4 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/toanppp/zgo + +go 1.22.1 diff --git a/message.go b/message.go new file mode 100644 index 0000000..d0d2b4e --- /dev/null +++ b/message.go @@ -0,0 +1,148 @@ +package zgo + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" +) + +const ( + SrcOA = iota + SrcUser +) + +type GetConversationResp struct { + Data Conversation `json:"data"` + ErrorResp +} + +type Conversation []Message + +func (c Conversation) Profile() Profile { + if len(c) == 0 { + return Profile{} + } + switch c[0].Src { + case SrcOA: + return Profile{ + UserID: c[0].ToID, + DisplayName: c[0].ToDisplayName, + } + case SrcUser: + return Profile{ + UserID: c[0].FromID, + DisplayName: c[0].FromDisplayName, + } + } + return Profile{} +} + +type Profile struct { + UserID string `json:"user_id"` + DisplayName string `json:"display_name"` +} + +type Message struct { + Src int `json:"src" bson:"src"` + Time int64 `json:"time" bson:"time"` + SentTime string `json:"sent_time" bson:"sent_time"` + FromID string `json:"from_id" bson:"from_id"` + FromDisplayName string `json:"from_display_name" bson:"from_display_name"` + FromAvatar string `json:"from_avatar" bson:"from_avatar"` + ToID string `json:"to_id" bson:"to_id"` + ToDisplayName string `json:"to_display_name" bson:"to_display_name"` + ToAvatar string `json:"to_avatar" bson:"to_avatar"` + MessageID string `json:"message_id" bson:"message_id"` + Type string `json:"type" bson:"type"` + Message string `json:"message" bson:"message"` + Links []Link `json:"links,omitempty" bson:"links"` + Thumb string `json:"thumb" bson:"thumb"` + URL string `json:"url" bson:"url"` + Description string `json:"description" bson:"description"` +} + +type Link struct { + Title string `json:"title" bson:"title"` + URL string `json:"url" bson:"url"` + Thumb string `json:"thumb" bson:"thumb"` + Description string `json:"description" bson:"description"` +} + +type LinkDescription struct { + Caption string `json:"caption"` + Phone string `json:"phone"` +} + +func (l Link) String() string { + var data LinkDescription + if err := json.Unmarshal([]byte(l.Description), &data); err != nil { + return l.URL + } + + switch l.URL { + case "https://zalo.me": + return data.Caption + case "www.zaloapp.com": + return fmt.Sprintf("Danh thiếp: %s - %s", l.Title, data.Phone) + default: + return l.URL + } +} + +type GetConversationReq struct { + UserID int64 `json:"user_id"` + Offset int `json:"offset"` + Count int `json:"count"` +} + +func (z *zgo) GetConversation(ctx context.Context, accessToken string, reqData GetConversationReq) (GetConversationResp, error) { + u, err := url.Parse(getConversationURL) + if err != nil { + return GetConversationResp{}, err + } + + bytes, err := json.Marshal(reqData) + if err != nil { + return GetConversationResp{}, err + } + params := url.Values{} + params.Set("data", string(bytes)) + u.RawQuery = params.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return GetConversationResp{}, err + } + + req.Header.Set("access_token", accessToken) + + resp, err := z.httpClient.Do(req) + if err != nil { + return GetConversationResp{}, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return GetConversationResp{}, errors.New("request failed") + } + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return GetConversationResp{}, err + } + + var data GetConversationResp + if err := json.Unmarshal(responseBody, &data); err != nil { + return GetConversationResp{}, err + } + + if data.Error != 0 { + return data, fmt.Errorf("request failed: %+v", data) + } + + return data, nil +} diff --git a/message_test.go b/message_test.go new file mode 100644 index 0000000..5433949 --- /dev/null +++ b/message_test.go @@ -0,0 +1,51 @@ +package zgo_test + +import ( + "testing" + + "github.com/toanppp/zgo" +) + +func TestLink_String(t *testing.T) { + type fields struct { + Title string + URL string + Thumb string + Description string + } + tests := []struct { + name string + fields fields + want string + }{ + { + name: "link", + fields: fields{ + URL: "https://google.com", + }, + want: "https://google.com", + }, + { + name: "number", + fields: fields{ + Title: "Name", + URL: "https://zalo.me", + Description: "{\"phone\":\"767263039\",\"caption\":\"767263039\",\"qrCodeUrl\":\"https:\\/\\/qr-talk.zdn.vn\\/9\\/77327221\\/b7b2f11d85566c083547.jpg\"}", + }, + want: "767263039", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l := zgo.Link{ + Title: tt.fields.Title, + URL: tt.fields.URL, + Thumb: tt.fields.Thumb, + Description: tt.fields.Description, + } + if got := l.String(); got != tt.want { + t.Errorf("String() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/zgo.go b/zgo.go new file mode 100644 index 0000000..3f81a67 --- /dev/null +++ b/zgo.go @@ -0,0 +1,60 @@ +package zgo + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "net/http" +) + +const ( + getAccessTokenURL = "https://oauth.zaloapp.com/v4/oa/access_token" + getConversationURL = "https://openapi.zalo.me/v2.0/oa/conversation" +) + +type Zgo interface { + GetAppID() string + EventSignature(data string, timestamp string) string + RefreshAccessToken(ctx context.Context, refreshToken string) (GetAccessTokenResp, error) + GetConversation(ctx context.Context, accessToken string, reqData GetConversationReq) (GetConversationResp, error) +} + +type zgo struct { + appID string + appSecret string + oaSecret string + httpClient *http.Client +} + +type Option func(z *zgo) + +func OptionHTTPClient(client *http.Client) func(*zgo) { + return func(z *zgo) { + z.httpClient = client + } +} + +func New(appID, appSecret, oaSecret string, opts ...Option) Zgo { + z := &zgo{ + appID: appID, + appSecret: appSecret, + oaSecret: oaSecret, + httpClient: http.DefaultClient, + } + + for _, opt := range opts { + opt(z) + } + + return z +} + +func (z *zgo) GetAppID() string { + return z.appID +} + +func (z *zgo) EventSignature(data string, timestamp string) string { + data = z.appID + data + timestamp + z.oaSecret + sum := sha256.Sum256([]byte(data)) + return hex.EncodeToString(sum[:]) +} diff --git a/zgo_test.go b/zgo_test.go new file mode 100644 index 0000000..0f5793f --- /dev/null +++ b/zgo_test.go @@ -0,0 +1,57 @@ +package zgo_test + +import ( + "testing" + + "github.com/toanppp/zgo" +) + +func TestApp_EventSignature(t *testing.T) { + type fields struct { + appID string + oaSecret string + } + type args struct { + data string + timestamp string + } + tests := []struct { + name string + fields fields + args args + want string + }{ + { + name: "remove_user_from_tag", + fields: fields{ + appID: "appID", + oaSecret: "oaSecret", + }, + args: args{ + data: `{"oa_id":"oa_id","user_id_by_app":"user_id_by_app","event_name":"remove_user_from_tag","tag":{"user_ids":["user_id"],"name":"Hoàn thành"},"app_id":"app_id","timestamp":"1702095007436"}`, + timestamp: "1702095007436", + }, + want: "315f0bb0c84fa87c816e302a590f5d26e6112a950c81bf28fb6b795130a19d09", + }, + { + name: "add_user_to_tag", + fields: fields{ + appID: "appID", + oaSecret: "oaSecret", + }, + args: args{ + data: `{"oa_id":"oa_id","user_id_by_app":"user_id_by_app","event_name":"add_user_to_tag","tag":{"user_ids":["user_id"],"name":"Hoàn thành"},"app_id":"app_id","timestamp":"1702095015762"}`, + timestamp: "1702095015762", + }, + want: "f0866f93f29edf589f83fb9edb3b57c47fc34eef8bfd71dd6a00daa517382007", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := zgo.New(tt.fields.appID, "", tt.fields.oaSecret) + if got := a.EventSignature(tt.args.data, tt.args.timestamp); got != tt.want { + t.Errorf("EventSignature() = %v, want %v", got, tt.want) + } + }) + } +}