First major code push

Preparing for version 0.1.0
Contains a real README, the test framework, and the API implementation
This commit is contained in:
densestvoid 2020-07-29 22:20:57 -04:00
parent d015059acc
commit 31885d2726
32 changed files with 4555 additions and 2 deletions

84
README.md Normal file → Executable file
View File

@ -1,2 +1,82 @@
# groupme
GroupMe Library
# Version 1.0 Release Date: TBD
I would like to add common helper functions/features inspired by the package use in the community. So please, especially before Version 1.0 release, let me know what you would like to see added to the package, but bear in mind the main objective to be a simple wrapper for the API exposed by the GroupMe team.
<br>
# GroupMe API Wrapper
## Description
The design of this package is meant to be super simple. Wrap the exposed API endpoints [documented](https://dev.groupme.com/docs/v3#v3) by the GroupMe team. While you can achieve the core of this package with cURL, there are some small added features, coupled along with a modern language, that should simplify writing GroupMe [bots](https://dev.groupme.com/bots) and [applications](https://dev.groupme.com/applications).
[*FUTURE*] In addition to the Go package, there is also a CLI application built using this package; all the features are available from the command line.
## Why?
I enjoy programming, I use GroupMe with friends, and I wanted to write a fun add-on application for our group. I happened to start using Go around this time, so it was good practice.
## Example
```golang
package main
import (
"fmt"
"github.com/densestvoid/groupme"
)
// This is not a real token. Please find yours by logging
// into the GroupMe development website: https://dev.groupme.com/
const authorizationToken = "0123456789ABCDEF"
// A short program that gets the gets the first 5 groups
// the user is part of, and then the first 10 messages of
// the first group in that list
func main() {
// Create a new client with your auth token
client := groupme.NewClient(authorizationToken)
// Get the groups your user is part of
groups, err := client.IndexGroups(&GroupsQuery{
Page: 0,
PerPage: 5,
Omit: "memberships",
})
if err != nil {
fmt.Println(err)
return
}
fmt.Println(groups)
// Get first 10 messages of the first group
if len(groups) <= 0 {
fmt.Println("No groups")
}
messages, err := client.IndexMessages(groups[0].ID, &IndexMessagesQuery{
Limit: 10,
})
if err != nil {
fmt.Println(err)
}
fmt.Println(messages)
}
```
## Installation
### Go Package
`go get github.com/densestvoid/groupme`
### [*FUTURE*] CLI
## Contribute
I find the hours I can spend developing personal projects decreasing every year, so I welcome any help I can get. Feel free to tackle any open issues, or if a feature request catches your eye, feel free to reach out to me and we can discuss adding it to the package. However, once version 1.0 is released, I don't foresee much work happening on this project unless the GroupMe API is updated.
## Credits
All credits for the actual platform belong to the GroupMe team; I only used the exposed API they wrote.
## License
GPL-3.0 License © [DensestVoid](https://github.com/densestvoid)

151
blocks_api.go Normal file
View File

@ -0,0 +1,151 @@
package groupme
import (
"net/http"
)
// GroupMe documentation: https://dev.groupme.com/docs/v3#blocks
////////// Endpoints //////////
const (
// Used to build other endpoints
blocksEndpointRoot = "/blocks"
// Actual Endpoints
indexBlocksEndpoint = blocksEndpointRoot // GET
blockBetweenEndpoint = blocksEndpointRoot + "/between" // GET
createBlockEndpoint = blocksEndpointRoot // POST
unblockEndpoint = blocksEndpointRoot // DELETE
)
////////// API Requests //////////
// Index
/*
IndexBlock -
A list of contacts you have blocked. These people cannot DM you
Parameters:
userID - required, ID(string)
*/
func (c *Client) IndexBlock(userID ID) ([]*Block, error) {
httpReq, err := http.NewRequest("GET", c.endpointBase+indexBlocksEndpoint, nil)
if err != nil {
return nil, err
}
URL := httpReq.URL
query := URL.Query()
query.Set("user", userID.String())
URL.RawQuery = query.Encode()
var resp struct {
Blocks []*Block `json:"blocks"`
}
err = c.do(httpReq, &resp)
if err != nil {
return nil, err
}
return resp.Blocks, nil
}
// Between
/*
BlockBetween -
Asks if a block exists between you and another user id
Parameters:
otherUserID - required, ID(string)
*/
func (c *Client) BlockBetween(userID, otherUserID ID) (bool, error) {
httpReq, err := http.NewRequest("GET", c.endpointBase+blockBetweenEndpoint, nil)
if err != nil {
return false, err
}
URL := httpReq.URL
query := URL.Query()
query.Set("user", userID.String())
query.Set("otherUser", otherUserID.String())
URL.RawQuery = query.Encode()
var resp struct {
Between bool `json:"between"`
}
err = c.do(httpReq, &resp)
if err != nil {
return false, err
}
return resp.Between, nil
}
// Create
/*
CreateBlock -
Creates a block between you and the contact
Parameters:
userID - required, ID(string)
otherUserID - required, ID(string)
*/
func (c *Client) CreateBlock(userID, otherUserID ID) (*Block, error) {
httpReq, err := http.NewRequest("POST", c.endpointBase+createBlockEndpoint, nil)
if err != nil {
return nil, err
}
URL := httpReq.URL
query := URL.Query()
query.Set("user", userID.String())
query.Set("otherUser", otherUserID.String())
URL.RawQuery = query.Encode()
var resp struct {
Block *Block `json:"block"`
}
err = c.do(httpReq, &resp)
if err != nil {
return nil, err
}
return resp.Block, nil
}
// Unblock
/*
Unblock -
Removes block between you and other user
Parameters:
userID - required, ID(string)
otherUserID - required, ID(string)
*/
func (c *Client) Unblock(userID, otherUserID ID) error {
httpReq, err := http.NewRequest("DELETE", c.endpointBase+unblockEndpoint, nil)
if err != nil {
return err
}
URL := httpReq.URL
query := URL.Query()
query.Set("user", userID.String())
query.Set("otherUser", otherUserID.String())
URL.RawQuery = query.Encode()
err = c.do(httpReq, nil)
if err != nil {
return err
}
return nil
}

126
blocks_api_test.go Normal file
View File

@ -0,0 +1,126 @@
package groupme
import (
"fmt"
"net/http"
"testing"
"github.com/gorilla/mux"
"github.com/stretchr/testify/suite"
)
type BlocksAPISuite struct{ APISuite }
func (s *BlocksAPISuite) SetupSuite() {
s.handler = blocksTestRouter()
s.setupSuite()
}
func (s *BlocksAPISuite) TestBlocksIndex() {
blocks, err := s.client.IndexBlock("1")
s.Require().NoError(err)
s.Require().NotZero(blocks)
for _, block := range blocks {
s.Assert().NotZero(block)
}
}
func (s *BlocksAPISuite) TestBlocksBetween() {
between, err := s.client.BlockBetween("1", "2")
s.Require().NoError(err)
s.Assert().True(between)
}
func (s *BlocksAPISuite) TestBlocksCreate() {
block, err := s.client.CreateBlock("1", "2")
s.Require().NoError(err)
s.Assert().NotZero(block)
}
func (s *BlocksAPISuite) TestBlocksUnblock() {
s.Assert().NoError(s.client.Unblock("1", "2"))
}
func TestBlocksAPISuite(t *testing.T) {
suite.Run(t, new(BlocksAPISuite))
}
func blocksTestRouter() *mux.Router {
router := mux.NewRouter()
// Index
router.Path("/blocks").
Queries("user", "").
Methods("GET").
Name("IndexBlock").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
fmt.Fprint(w, `{
"response": {
"blocks": [
{
"user_id": "1234567890",
"blocked_user_id": "1234567890",
"created_at": 1302623328
}
]
},
"meta": {
"code": 200,
"errors": []
}
}`)
})
// Block Between
router.Path("/blocks/between").
Queries("user", "", "otherUser", "").
Methods("GET").
Name("BlockBetween").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
fmt.Fprint(w, `{
"response": {
"between": true
},
"meta": {
"code": 200,
"errors": []
}
}`)
})
// Create Block
router.Path("/blocks").
Queries("user", "", "otherUser", "").
Methods("POST").
Name("CreateBlock").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
fmt.Fprint(w, `{
"response": {
"block": {
"user_id": "1234567890",
"blocked_user_id": "1234567890",
"created_at": 1302623328
}
},
"meta": {
"code": 200,
"errors": []
}
}`)
})
// Unblock
router.Path("/blocks").
Queries("user", "", "otherUser", "").
Methods("DELETE").
Name("Unblock").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
})
/*// Return test router //*/
return router
}

142
bots_api.go Normal file
View File

@ -0,0 +1,142 @@
package groupme
import (
"errors"
"fmt"
"net/http"
"net/url"
)
// GroupMe documentation: https://dev.groupme.com/docs/v3#bots
////////// Endpoints //////////
const (
// Used to build other endpoints
botsEndpointRoot = "/bots"
// Actual Endpoints
createBotEndpoint = botsEndpointRoot // POST
postBotMessageEndpoint = botsEndpointRoot + "/post" // POST
indexBotsEndpoint = botsEndpointRoot // GET
destroyBotEndpoint = botsEndpointRoot + "/destroy" // POST
)
////////// API Requests //////////
// Create
/*
CreateBot -
Create a bot. See the Bots Tutorial (https://dev.groupme.com/tutorials/bots)
for a full walkthrough.
Parameters:
See Bot
Name - required
GroupID - required
*/
func (c *Client) CreateBot(bot *Bot) (*Bot, error) {
httpReq, err := http.NewRequest("POST", c.endpointBase+createBotEndpoint, nil)
if err != nil {
return nil, err
}
if bot == nil {
return nil, errors.New("bot cannot be nil")
}
data := url.Values{}
data.Add("bot", bot.String())
httpReq.PostForm = data
var resp Bot
err = c.do(httpReq, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}
// PostMessage
/*
PostBotMessage -
Post a message from a bot
Parameters:
botID - required, ID(string)
text - required, string
pictureURL - string; image must be processed through image
service (https://dev.groupme.com/docs/image_service)
*/
func (c *Client) PostBotMessage(botID ID, text string, pictureURL *string) error {
URL := fmt.Sprintf(c.endpointBase + postBotMessageEndpoint)
httpReq, err := http.NewRequest("POST", URL, nil)
if err != nil {
return err
}
data := url.Values{}
data.Add("bot_id", string(botID))
data.Add("text", text)
if pictureURL != nil {
data.Add("picture_url", *pictureURL)
}
httpReq.PostForm = data
return c.do(httpReq, nil)
}
// Index
/*
IndexBots -
List bots that you have created
*/
func (c *Client) IndexBots() ([]*Bot, error) {
httpReq, err := http.NewRequest("GET", c.endpointBase+indexBotsEndpoint, nil)
if err != nil {
return nil, err
}
var resp []*Bot
err = c.do(httpReq, &resp)
if err != nil {
return nil, err
}
return resp, nil
}
// Destroy
/*
DestroyBot -
Remove a bot that you have created
Parameters:
botID - required, ID(string)
*/
func (c *Client) DestroyBot(botID ID) error {
URL := fmt.Sprintf(c.endpointBase + destroyBotEndpoint)
httpReq, err := http.NewRequest("POST", URL, nil)
if err != nil {
return err
}
data := url.Values{}
data.Add("bot_id", string(botID))
httpReq.PostForm = data
return c.do(httpReq, nil)
}

120
bots_api_test.go Normal file
View File

@ -0,0 +1,120 @@
package groupme
import (
"fmt"
"net/http"
"testing"
"github.com/gorilla/mux"
"github.com/stretchr/testify/suite"
)
type BotsAPISuite struct{ APISuite }
func (s *BotsAPISuite) SetupSuite() {
s.handler = botsTestRouter()
s.setupSuite()
}
func (s *BotsAPISuite) TestBotsCreate() {
bot, err := s.client.CreateBot(&Bot{
Name: "test",
GroupID: "1",
AvatarURL: "url.com",
CallbackURL: "otherURL.com",
DMNotification: true,
})
s.Require().NoError(err)
s.Require().NotZero(bot)
}
func (s *BotsAPISuite) TestBotsPostMessage() {
err := s.client.PostBotMessage("1", "test message", nil)
s.Require().NoError(err)
}
func (s *BotsAPISuite) TestBotsIndex() {
bots, err := s.client.IndexBots()
s.Require().NoError(err)
s.Require().NotZero(bots)
for _, bot := range bots {
s.Assert().NotZero(bot)
}
}
func (s *BotsAPISuite) TestBotsDestroy() {
s.Require().NoError(s.client.DestroyBot("1"))
}
func TestBotsAPISuite(t *testing.T) {
suite.Run(t, new(BotsAPISuite))
}
func botsTestRouter() *mux.Router {
router := mux.NewRouter()
// Create
router.Path("/bots").
Methods("POST").
Name("CreateBot").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(201)
fmt.Fprint(w, `{
"response": {
"bot_id": "1234567890",
"group_id": "1234567890",
"name": "hal9000",
"avatar_url": "https://i.groupme.com/123456789",
"callback_url": "https://example.com/bots/callback",
"dm_notification": false
},
"meta": {
"code": 201,
"errors": []
}
}`)
})
// Post Message
router.Path("/bots/post").
Methods("POST").
Name("PostBotMessage").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(201)
})
// Index
router.Path("/bots").
Methods("GET").
Name("IndexBots").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
fmt.Fprint(w, `{
"response": [
{
"bot_id": "1234567890",
"group_id": "1234567890",
"name": "hal9000",
"avatar_url": "https://i.groupme.com/123456789",
"callback_url": "https://example.com/bots/callback",
"dm_notification": false
}
],
"meta": {
"code": 200,
"errors": []
}
}`)
})
// Destroy
router.Path("/bots/destroy").
Methods("POST").
Name("DestroyBot").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(201)
})
/*// Return test router //*/
return router
}

61
chats_api.go Normal file
View File

@ -0,0 +1,61 @@
package groupme
import (
"net/http"
"strconv"
)
// GroupMe documentation: https://dev.groupme.com/docs/v3#chats
////////// Endpoints //////////
const (
// Used to build other endpoints
chatsEndpointRoot = "/chats"
indexChatsEndpoint = chatsEndpointRoot // GET
)
// Index
// ChatsQuery defines the optional URL parameters for IndexChats
type IndexChatsQuery struct {
// Page Number
Page int `json:"page"`
// Number of chats per page
PerPage int `json:"per_page"`
}
/*
IndexChats -
Returns a paginated list of direct message chats, or
conversations, sorted by updated_at descending.
Parameters: See ChatsQuery
*/
func (c *Client) IndexChats(req *IndexChatsQuery) ([]*Chat, error) {
httpReq, err := http.NewRequest("GET", c.endpointBase+indexChatsEndpoint, nil)
if err != nil {
return nil, err
}
URL := httpReq.URL
query := URL.Query()
if req != nil {
if req.Page > 0 {
query.Set("page", strconv.Itoa(req.Page))
}
if req.PerPage > 0 {
query.Set("per_page", strconv.Itoa(req.PerPage))
}
}
URL.RawQuery = query.Encode()
var resp []*Chat
err = c.do(httpReq, &resp)
if err != nil {
return nil, err
}
return resp, nil
}

87
chats_api_test.go Normal file
View File

@ -0,0 +1,87 @@
package groupme
import (
"fmt"
"net/http"
"testing"
"github.com/gorilla/mux"
"github.com/stretchr/testify/suite"
)
type ChatsAPISuite struct{ APISuite }
func (s *ChatsAPISuite) SetupSuite() {
s.handler = chatsTestRouter()
s.setupSuite()
}
func (s *ChatsAPISuite) TestChatsIndex() {
chats, err := s.client.IndexChats(
&IndexChatsQuery{
Page: 1,
PerPage: 20,
},
)
s.Require().NoError(err)
s.Require().NotZero(chats)
for _, chat := range chats {
s.Assert().NotZero(chat)
}
}
func TestChatsAPISuite(t *testing.T) {
suite.Run(t, new(ChatsAPISuite))
}
func chatsTestRouter() *mux.Router {
router := mux.NewRouter()
// Index
router.Path("/chats").
Methods("GET").
Name("IndexChats").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
fmt.Fprint(w, `{
"response": [
{
"created_at": 1352299338,
"updated_at": 1352299338,
"last_message": {
"attachments": [
],
"avatar_url": "https://i.groupme.com/200x200.jpeg.abcdef",
"conversation_id": "12345+67890",
"created_at": 1352299338,
"favorited_by": [
],
"id": "1234567890",
"name": "John Doe",
"recipient_id": "67890",
"sender_id": "12345",
"sender_type": "user",
"source_guid": "GUID",
"text": "Hello world",
"user_id": "12345"
},
"messages_count": 10,
"other_user": {
"avatar_url": "https://i.groupme.com/200x200.jpeg.abcdef",
"id": "12345",
"name": "John Doe"
}
}
],
"meta": {
"code": 200,
"errors": []
}
}`)
})
/*// Return test router //*/
return router
}

92
client.go Normal file
View File

@ -0,0 +1,92 @@
package groupme
import (
"bytes"
"encoding/json"
"errors"
"io/ioutil"
"net/http"
)
// Endpoints are added on to the GroupMeAPIBase to get the full URI.
// Overridable for testing
const GroupMeAPIBase = "https://api.groupme.com/v3"
// Client communicates with the GroupMe API to perform actions
// on the basic types, i.e. Listing, Creating, Destroying
type Client struct {
httpClient *http.Client
endpointBase string
authorizationToken string
}
// NewClient creates a new GroupMe API Client
func NewClient(authToken string) *Client {
return &Client{
httpClient: new(http.Client),
endpointBase: GroupMeAPIBase,
authorizationToken: authToken,
}
}
// Close safely shuts down the Client
func (c *Client) Close() error {
c.httpClient.CloseIdleConnections()
return nil
}
// String returns a json formatted string
func (c Client) String() string {
return marshal(&c)
}
///// Handle parsing of nested interface type response /////
type jsonResponse struct {
Response response `json:"response"`
Meta `json:"meta"`
}
func newJSONResponse(i interface{}) *jsonResponse {
return &jsonResponse{Response: response{i}}
}
type response struct {
i interface{}
}
func (r response) UnmarshalJSON(bs []byte) error {
return json.NewDecoder(bytes.NewBuffer(bs)).Decode(r.i)
}
func (c Client) do(req *http.Request, i interface{}) error {
getResp, err := c.httpClient.Do(req)
if err != nil {
return err
}
// Check Status Code is 1XX or 2XX
if getResp.StatusCode/100 > 2 {
return errors.New(getResp.Status)
}
bytes, err := ioutil.ReadAll(getResp.Body)
if err != nil {
return err
}
if i == nil {
return nil
}
resp := newJSONResponse(i)
if err := json.Unmarshal(bytes, &resp); err != nil {
return err
}
// Check Status Code is 1XX or 2XX
if resp.Meta.Code/100 > 2 {
return &resp.Meta
}
return nil
}

49
client_test.go Normal file
View File

@ -0,0 +1,49 @@
package groupme
import (
"net/http"
"testing"
"github.com/stretchr/testify/suite"
)
type ClientSuite struct{ APISuite }
func (s *ClientSuite) SetupSuite() {
serverMux := http.NewServeMux()
serverMux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
_, err := w.Write([]byte("error"))
s.Require().NoError(err)
})
s.handler = serverMux
s.setupSuite()
}
func (s *ClientSuite) SetupTest() {
s.client = NewClient("")
s.Require().NotNil(s.client)
s.client.endpointBase = s.addr
}
func (s *ClientSuite) TestClient_Close() {
s.Assert().NoError(s.client.Close())
}
func (s *ClientSuite) TestClient_do_DoError() {
req, err := http.NewRequest("", "", nil)
s.Require().NoError(err)
s.Assert().Error(s.client.do(req, struct{}{}))
}
func (s *ClientSuite) TestClient_do_UnmarshalError() {
req, err := http.NewRequest("GET", s.addr, nil)
s.Require().NoError(err)
s.Assert().Error(s.client.do(req, struct{}{}))
}
func TestClientSuite(t *testing.T) {
suite.Run(t, new(ClientSuite))
}

94
data_types.go Normal file
View File

@ -0,0 +1,94 @@
package groupme
import (
"regexp"
"time"
)
// GroupMe documentation: https://dev.groupme.com/docs/responses
// ID is an unordered alphanumeric string
type ID string
// Treated as a constant
var alphaNumericRegex = regexp.MustCompile(`^[a-zA-Z0-9]+$`)
// Valid checks if the ID string is alpha numeric
func (id ID) Valid() bool {
return alphaNumericRegex.MatchString(string(id))
}
func (id ID) String() string {
return string(id)
}
// Timestamp is the number of seconds since the UNIX epoch
type Timestamp uint64
// FromTime returns the time.Time as a Timestamp
func FromTime(t time.Time) Timestamp {
return Timestamp(t.Unix())
}
// ToTime returns the Timestamp as a UTC Time
func (t Timestamp) ToTime() time.Time {
return time.Unix(int64(t), 0).UTC()
}
// String returns the Timestamp in the default time.Time string format
func (t Timestamp) String() string {
return t.ToTime().String()
}
// PhoneNumber is the country code plus the number of the user
type PhoneNumber string
// Treated as a constant
var phoneNumberRegex = regexp.MustCompile(`^\+[0-9]+ [0-9]{10}$`)
// Valid checks if the ID string is alpha numeric
func (pn PhoneNumber) Valid() bool {
return phoneNumberRegex.MatchString(string(pn))
}
func (pn PhoneNumber) String() string {
return string(pn)
}
// StatusCodes are returned by HTTP requests in
// the header and the json "meta" field
type HTTPStatusCode int
// Text used as constant name
const (
HTTP_Ok HTTPStatusCode = 200
HTTP_Created HTTPStatusCode = 201
HTTP_NoContent HTTPStatusCode = 204
HTTP_NotModified HTTPStatusCode = 304
HTTP_BadRequest HTTPStatusCode = 400
HTTP_Unauthorized HTTPStatusCode = 401
HTTP_Forbidden HTTPStatusCode = 403
HTTP_NotFound HTTPStatusCode = 404
HTTP_EnhanceYourCalm HTTPStatusCode = 420
HTTP_InternalServerError HTTPStatusCode = 500
HTTP_BadGateway HTTPStatusCode = 502
HTTP_ServiceUnavailable HTTPStatusCode = 503
)
// String returns the description of the status code according to GroupMe
func (c HTTPStatusCode) String() string {
return map[HTTPStatusCode]string{
HTTP_Ok: "success",
HTTP_Created: "resource was created successfully",
HTTP_NoContent: "resource was deleted successfully",
HTTP_NotModified: "no new data to return",
HTTP_BadRequest: "invalid format or data specified in the request",
HTTP_Unauthorized: "authentication credentials missing or incorrect",
HTTP_Forbidden: "request refused due to update limits",
HTTP_NotFound: "URI is invalid or resource does not exist",
HTTP_EnhanceYourCalm: "application is being rate limited",
HTTP_InternalServerError: "something unexpected occurred",
HTTP_BadGateway: "GroupMe is down or being upgraded",
HTTP_ServiceUnavailable: "servers are overloaded, try again later",
}[c]
}

60
data_types_test.go Normal file
View File

@ -0,0 +1,60 @@
package groupme
import (
"testing"
"time"
"github.com/stretchr/testify/suite"
)
type DataTypesSuite struct {
suite.Suite
}
func (s *DataTypesSuite) TestID_Valid_True() {
var id ID = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
s.Assert().True(id.Valid())
}
func (s *DataTypesSuite) TestID_Valid_False() {
var id ID = "`~!@#$%^&*()_-+={[}]:;\"'<,>.?/|\\"
s.Assert().False(id.Valid())
}
func (s *DataTypesSuite) TestTimestamp_FromTime() {
t := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
expected := Timestamp(0)
actual := FromTime(t)
s.Assert().EqualValues(expected, actual)
}
func (s *DataTypesSuite) TestTimestamp_ToTime() {
t := Timestamp(0)
expected := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
actual := t.ToTime()
s.Assert().EqualValues(expected, actual)
}
func (s *DataTypesSuite) TestPhoneNumber_Valid_True() {
var pn PhoneNumber = "+1 0123456789"
s.Assert().True(pn.Valid())
}
func (s *DataTypesSuite) TestPhoneNumber_Valid_NoPlus() {
var pn PhoneNumber = "1 0123456789"
s.Assert().False(pn.Valid())
}
func (s *DataTypesSuite) TestPhoneNumber_Valid_NoSpace() {
var pn PhoneNumber = "+10123456789"
s.Assert().False(pn.Valid())
}
func (s *DataTypesSuite) TestPhoneNumber_Valid_BadLength() {
var pn PhoneNumber = "+1 01234567890"
s.Assert().False(pn.Valid())
}
func TestDataTypesSuite(t *testing.T) {
suite.Run(t, new(DataTypesSuite))
}

145
direct_messages_api.go Normal file
View File

@ -0,0 +1,145 @@
package groupme
import (
"fmt"
"net/http"
"net/url"
"github.com/google/uuid"
)
// GroupMe documentation: https://dev.groupme.com/docs/v3#direct_messages
////////// Endpoints //////////
const (
// Used to build other endpoints
directMessagesEndpointRoot = "/direct_messages"
// Actual Endpoints
indexDirectMessagesEndpoint = directMessagesEndpointRoot // GET
createDirectMessageEndpoint = directMessagesEndpointRoot // POST
)
////////// API Requests //////////
// Index
// MessagesQuery defines the optional URL parameters for IndexDirectMessages
type IndexDirectMessagesQuery struct {
// Returns 20 messages created before the given message ID
BeforeID ID `json:"before_id"`
// Returns 20 messages created after the given message ID
SinceID ID `json:"since_id"`
}
func (q IndexDirectMessagesQuery) String() string {
return marshal(&q)
}
// IndexDirectMessagesResponse contains the count and set of
// messages returned by the IndexDirectMessages API request
type IndexDirectMessagesResponse struct {
Count int `json:"count"`
Messages []*Message `json:"direct_messages"`
}
func (r IndexDirectMessagesResponse) String() string {
return marshal(&r)
}
/*
IndexDirectMessages -
Fetch direct messages between two users.
DMs are returned in groups of 20, ordered by created_at
descending.
If no messages are found (e.g. when filtering with since_id) we
return code 304.
Note that for historical reasons, likes are returned as an array
of user ids in the favorited_by key.
Parameters:
otherUserID - required, ID(string); the other participant in the conversation.
See IndexDirectMessagesQuery
*/
func (c *Client) IndexDirectMessages(otherUserID ID, req *IndexDirectMessagesQuery) (IndexDirectMessagesResponse, error) {
httpReq, err := http.NewRequest("GET", c.endpointBase+indexDirectMessagesEndpoint, nil)
if err != nil {
return IndexDirectMessagesResponse{}, err
}
query := httpReq.URL.Query()
query.Set("other_user_id", otherUserID.String())
if req != nil {
if req.BeforeID != "" {
query.Add("before_ID", req.BeforeID.String())
}
if req.SinceID != "" {
query.Add("since_id", req.SinceID.String())
}
}
var resp IndexDirectMessagesResponse
err = c.do(httpReq, &resp)
if err != nil {
return IndexDirectMessagesResponse{}, err
}
return resp, nil
}
// Create
/*
CreateDirectMessage -
Send a DM to another user
If you want to attach an image, you must first process it
through our image service.
Attachments of type emoji rely on data from emoji PowerUps.
Clients use a placeholder character in the message text and
specify a replacement charmap to substitute emoji characters
The character map is an array of arrays containing rune data
([[{pack_id,offset}],...]).
Parameters:
See Message.
recipientID - required, ID(string); The GroupMe user ID of the recipient of this message.
text - required, string. Can be ommitted if at least one
attachment is present
attachments - a polymorphic list of attachments (locations,
images, etc). You may have You may have more than
one of any type of attachment, provided clients can
display it.
*/
func (c *Client) CreateDirectMessage(m *Message) (*Message, error) {
URL := fmt.Sprintf(c.endpointBase + createDirectMessageEndpoint)
httpReq, err := http.NewRequest("POST", URL, nil)
if err != nil {
return nil, err
}
m.SourceGUID = uuid.New().String()
data := url.Values{}
data.Set("direct_message", m.String())
httpReq.PostForm = data
var resp struct {
*Message `json:"message"`
}
err = c.do(httpReq, &resp)
if err != nil {
return nil, err
}
return resp.Message, nil
}

176
direct_messages_api_test.go Normal file
View File

@ -0,0 +1,176 @@
package groupme
import (
"fmt"
"net/http"
"testing"
"github.com/gorilla/mux"
"github.com/stretchr/testify/suite"
)
type DirectMessagesAPISuite struct{ APISuite }
func (s *DirectMessagesAPISuite) SetupSuite() {
s.handler = directMessagesTestRouter()
s.setupSuite()
}
func (s *DirectMessagesAPISuite) TestDirectMessagesIndex() {
resp, err := s.client.IndexDirectMessages(
ID("123"),
&IndexDirectMessagesQuery{
BeforeID: "0123456789",
SinceID: "9876543210",
},
)
s.Require().NoError(err)
s.Require().NotZero(resp)
for _, message := range resp.Messages {
s.Assert().NotZero(message)
}
}
func (s *DirectMessagesAPISuite) TestDirectMessagesCreate() {
message, err := s.client.CreateDirectMessage(
&Message{
RecipientID: ID("123"),
Text: "Test",
},
)
s.Require().NoError(err)
s.Require().NotNil(message)
s.Assert().NotZero(*message)
}
func TestDirectMessagesAPISuite(t *testing.T) {
suite.Run(t, new(DirectMessagesAPISuite))
}
func directMessagesTestRouter() *mux.Router {
router := mux.NewRouter()
// Index
router.Path("/direct_messages").
Methods("GET").
Name("IndexDirectMessages").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
fmt.Fprint(w, `{
"response": {
"count": 123,
"direct_messages": [
{
"id": "1234567890",
"source_guid": "GUID",
"recipient_id": "20",
"user_id": "1234567890",
"created_at": 1302623328,
"name": "John",
"avatar_url": "https://i.groupme.com/123456789",
"text": "Hello world ☃☃",
"favorited_by": [
"101"
],
"attachments": [
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "location",
"lat": "40.738206",
"lng": "-73.993285",
"name": "GroupMe HQ"
},
{
"type": "emoji",
"placeholder": "☃",
"charmap": [
[
1,
42
],
[
2,
34
]
]
}
]
}
]
},
"meta": {
"code": 200,
"errors": []
}
}`)
})
// Create
router.Path("/direct_messages").
Methods("POST").
Name("CreateDirectMessage").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(201)
fmt.Fprint(w, `{
"response": {
"message": {
"id": "1234567890",
"source_guid": "GUID",
"recipient_id": "20",
"user_id": "1234567890",
"created_at": 1302623328,
"name": "John",
"avatar_url": "https://i.groupme.com/123456789",
"text": "Hello world ☃☃",
"favorited_by": [
"101"
],
"attachments": [
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "location",
"lat": "40.738206",
"lng": "-73.993285",
"name": "GroupMe HQ"
},
{
"type": "emoji",
"placeholder": "☃",
"charmap": [
[
1,
42
],
[
2,
34
]
]
}
]
}
},
"meta": {
"code": 201,
"errors": []
}
}`)
})
/*// Return test router //*/
return router
}

18
go.mod Executable file
View File

@ -0,0 +1,18 @@
module github.com/densestvoid/groupme
go 1.14
require (
github.com/go-critic/go-critic v0.5.0 // indirect
github.com/go-toolsmith/astinfo v1.0.0 // indirect
github.com/google/go-cmp v0.5.1 // indirect
github.com/google/uuid v1.1.1
github.com/gorilla/mux v1.7.4
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
github.com/mattn/goveralls v0.0.6 // indirect
github.com/quasilyte/go-consistent v0.0.0-20200404105227-766526bf1e96 // indirect
github.com/quasilyte/go-ruleguard v0.1.3 // indirect
github.com/quasilyte/regex/syntax v0.0.0-20200419152657-af9db7f4a3ab // indirect
github.com/stretchr/testify v1.5.1
golang.org/x/tools v0.0.0-20200728190822-edd3c8e9e279 // indirect
)

105
go.sum Executable file
View File

@ -0,0 +1,105 @@
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-critic/go-critic v0.5.0 h1:Ic2p5UCl5fX/2WX2w8nroPpPhxRNsNTMlJzsu/uqwnM=
github.com/go-critic/go-critic v0.5.0/go.mod h1:4jeRh3ZAVnRYhuWdOEvwzVqLUpxMSoAT0xZ74JsTPlo=
github.com/go-lintpack/lintpack v0.5.2/go.mod h1:NwZuYi2nUHho8XEIZ6SIxihrnPoqBTDqfpXvXAN0sXM=
github.com/go-toolsmith/astcast v1.0.0 h1:JojxlmI6STnFVG9yOImLeGREv8W2ocNUM+iOhR6jE7g=
github.com/go-toolsmith/astcast v1.0.0/go.mod h1:mt2OdQTeAQcY4DQgPSArJjHCcOwlX+Wl/kwN+LbLGQ4=
github.com/go-toolsmith/astcopy v1.0.0 h1:OMgl1b1MEpjFQ1m5ztEO06rz5CUd3oBv9RF7+DyvdG8=
github.com/go-toolsmith/astcopy v1.0.0/go.mod h1:vrgyG+5Bxrnz4MZWPF+pI4R8h3qKRjjyvV/DSez4WVQ=
github.com/go-toolsmith/astequal v0.0.0-20180903214952-dcb477bfacd6/go.mod h1:H+xSiq0+LtiDC11+h1G32h7Of5O3CYFJ99GVbS5lDKY=
github.com/go-toolsmith/astequal v1.0.0 h1:4zxD8j3JRFNyLN46lodQuqz3xdKSrur7U/sr0SDS/gQ=
github.com/go-toolsmith/astequal v1.0.0/go.mod h1:H+xSiq0+LtiDC11+h1G32h7Of5O3CYFJ99GVbS5lDKY=
github.com/go-toolsmith/astfmt v0.0.0-20180903215011-8f8ee99c3086/go.mod h1:mP93XdblcopXwlyN4X4uodxXQhldPGZbcEJIimQHrkg=
github.com/go-toolsmith/astfmt v1.0.0 h1:A0vDDXt+vsvLEdbMFJAUBI/uTbRw1ffOPnxsILnFL6k=
github.com/go-toolsmith/astfmt v1.0.0/go.mod h1:cnWmsOAuq4jJY6Ct5YWlVLmcmLMn1JUPuQIHCY7CJDw=
github.com/go-toolsmith/astinfo v0.0.0-20180906194353-9809ff7efb21 h1:wP6mXeB2V/d1P1K7bZ5vDUO3YqEzcvOREOxZPEu3gVI=
github.com/go-toolsmith/astinfo v0.0.0-20180906194353-9809ff7efb21/go.mod h1:dDStQCHtmZpYOmjRP/8gHHnCCch3Zz3oEgCdZVdtweU=
github.com/go-toolsmith/astinfo v1.0.0 h1:rNuhpyhsnsze/Pe1l/GUHwxo1rmN7Dyb6oAnFcrXh+w=
github.com/go-toolsmith/astinfo v1.0.0/go.mod h1:dDStQCHtmZpYOmjRP/8gHHnCCch3Zz3oEgCdZVdtweU=
github.com/go-toolsmith/astp v0.0.0-20180903215135-0af7e3c24f30/go.mod h1:SV2ur98SGypH1UjcPpCatrV5hPazG6+IfNHbkDXBRrk=
github.com/go-toolsmith/astp v1.0.0 h1:alXE75TXgcmupDsMK1fRAy0YUzLzqPVvBKoyWV+KPXg=
github.com/go-toolsmith/astp v1.0.0/go.mod h1:RSyrtpVlfTFGDYRbrjyWP1pYu//tSFcvdYrA8meBmLI=
github.com/go-toolsmith/pkgload v0.0.0-20181119091011-e9e65178eee8/go.mod h1:WoMrjiy4zvdS+Bg6z9jZH82QXwkcgCBX6nOfnmdaHks=
github.com/go-toolsmith/pkgload v1.0.0 h1:4DFWWMXVfbcN5So1sBNW9+yeiMqLFGl1wFLTL5R0Tgg=
github.com/go-toolsmith/pkgload v1.0.0/go.mod h1:5eFArkbO80v7Z0kdngIxsRXRMTaX4Ilcwuh3clNrQJc=
github.com/go-toolsmith/strparse v1.0.0 h1:Vcw78DnpCAKlM20kSbAyO4mPfJn/lyYA4BJUDxe2Jb4=
github.com/go-toolsmith/strparse v1.0.0/go.mod h1:YI2nUKP9YGZnL/L1/DLFBfixrcjslWct4wyljWhSRy8=
github.com/go-toolsmith/typep v1.0.0/go.mod h1:JSQCQMUPdRlMZFswiq3TGpNp1GMktqkR2Ns5AIQkATU=
github.com/go-toolsmith/typep v1.0.2 h1:8xdsa1+FSIH/RhEkgnD1j2CJOy5mNllW1Q9tRiYwvlk=
github.com/go-toolsmith/typep v1.0.2/go.mod h1:JSQCQMUPdRlMZFswiq3TGpNp1GMktqkR2Ns5AIQkATU=
github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.5.1 h1:JFrFEBb2xKufg6XkJsJr+WbKb4FQlURi5RUcBveYu9k=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc=
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/logrusorgru/aurora v0.0.0-20181002194514-a7b3b318ed4e h1:9MlwzLdW7QSDrhDjFlsEYmxpFyIoXmYRon3dt0io31k=
github.com/logrusorgru/aurora v0.0.0-20181002194514-a7b3b318ed4e/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
github.com/mattn/goveralls v0.0.2 h1:7eJB6EqsPhRVxvwEXGnqdO2sJI0PTsrWoTMXEk9/OQc=
github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
github.com/mattn/goveralls v0.0.6 h1:cr8Y0VMo/MnEZBjxNN/vh6G90SZ7IMb6lms1dzMoO+Y=
github.com/mattn/goveralls v0.0.6/go.mod h1:h8b4ow6FxSPMQHF6o2ve3qsclnffZjYTNEKmLesRwqw=
github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quasilyte/go-consistent v0.0.0-20190521200055-c6f3937de18c h1:JoUA0uz9U0FVFq5p4LjEq4C0VgQ0El320s3Ms0V4eww=
github.com/quasilyte/go-consistent v0.0.0-20190521200055-c6f3937de18c/go.mod h1:5STLWrekHfjyYwxBRVRXNOSewLJ3PWfDJd1VyTS21fI=
github.com/quasilyte/go-consistent v0.0.0-20200404105227-766526bf1e96 h1:6VBkISnfYpPtRvpE9wsVoxX+i0cDQFBPQPYzw259xWY=
github.com/quasilyte/go-consistent v0.0.0-20200404105227-766526bf1e96/go.mod h1:h5ob45vcE3sydtmo0lUDUmG3Y0HXudxMId1w+5G99VI=
github.com/quasilyte/go-ruleguard v0.1.2-0.20200318202121-b00d7a75d3d8 h1:DvnesvLtRPQOvaUbfXfh0tpMHg29by0H7F2U+QIkSu8=
github.com/quasilyte/go-ruleguard v0.1.2-0.20200318202121-b00d7a75d3d8/go.mod h1:CGFX09Ci3pq9QZdj86B+VGIdNj4VyCo2iPOGS9esB/k=
github.com/quasilyte/go-ruleguard v0.1.3 h1:6BU9UaNiSbTRYriG0PTZACIMi1dLHzIWWseSpX4icNM=
github.com/quasilyte/go-ruleguard v0.1.3/go.mod h1:CGFX09Ci3pq9QZdj86B+VGIdNj4VyCo2iPOGS9esB/k=
github.com/quasilyte/regex/syntax v0.0.0-20200407221936-30656e2c4a95 h1:L8QM9bvf68pVdQ3bCFZMDmnt9yqcMBro1pC7F+IPYMY=
github.com/quasilyte/regex/syntax v0.0.0-20200407221936-30656e2c4a95/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0=
github.com/quasilyte/regex/syntax v0.0.0-20200419152657-af9db7f4a3ab h1:rjBjlam2Bbr6Dwp0T8HY2paibXTjMsNQU7vUH8hB+C4=
github.com/quasilyte/regex/syntax v0.0.0-20200419152657-af9db7f4a3ab/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0=
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20181117154741-2ddaf7f79a09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190110163146-51295c7ec13a/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190521203540-521d6ed310dd h1:7E3PabyysDSEjnaANKBgums/hyvMI/HoHQ50qZEzTrg=
golang.org/x/tools v0.0.0-20190521203540-521d6ed310dd/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200522201501-cb1345f3a375/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200728190822-edd3c8e9e279 h1:VUQjqirfpXJk5i+LtIdDjCAqYrCqTarkUCmMVLqMmVQ=
golang.org/x/tools v0.0.0-20200728190822-edd3c8e9e279/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

417
groups_api.go Executable file
View File

@ -0,0 +1,417 @@
package groupme
import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
)
// GroupMe documentation: https://dev.groupme.com/docs/v3#groups
////////// Endpoints //////////
const (
// Used to build other endpoints
groupsEndpointRoot = "/groups"
groupEndpointRoot = "/groups/%s"
// Actual Endpoints
indexGroupsEndpoint = groupsEndpointRoot // GET
formerGroupsEndpoint = groupsEndpointRoot + "/former" // GET
showGroupEndpoint = groupEndpointRoot // GET
createGroupEndpoint = groupsEndpointRoot // POST
updateGroupEndpoint = groupEndpointRoot + "/update" // POST
destroyGroupEndpoint = groupEndpointRoot + "/destroy" // POST
joinGroupEndpoint = groupEndpointRoot + "/join/%s" // POST
rejoinGroupEndpoint = groupsEndpointRoot + "/join" // POST
changeGroupOwnerEndpoint = groupsEndpointRoot + "/change_owners" // POST
)
////////// Common Request Parameters //////////
// GroupSettings is the settings for a group, used by CreateGroup and UpdateGroup
type GroupSettings struct {
// Required. Primary name of the group. Maximum 140 characters
Name string `json:"name"`
// A subheading for the group. Maximum 255 characters
Description string `json:"description"`
// GroupMe Image Service URL
ImageURL string `json:"image_url"`
// Defaults false. If true, disables notifications for all members.
// Documented for use only for UpdateGroup
OfficeMode bool `json:"office_mode"`
// Defaults false. If true, generates a share URL.
// Anyone with the URL can join the group
Share bool `json:"share"`
}
func (gss GroupSettings) String() string {
return marshal(&gss)
}
////////// API Requests //////////
///// Index /////
// GroupsQuery defines optional URL parameters for IndexGroups
type GroupsQuery struct {
// Fetch a particular page of results. Defaults to 1.
Page int `json:"page"`
// Define page size. Defaults to 10.
PerPage int `json:"per_page"`
// Comma separated list of data to omit from output.
// Currently supported value is only "memberships".
// If used then response will contain empty (null) members field.
Omit string `json:"omit"`
}
func (q GroupsQuery) String() string {
return marshal(&q)
}
/*
IndexGroups -
List the authenticated user's active groups.
The response is paginated, with a default of 10 groups per page.
Please consider using of omit=memberships parameter. Not including
member lists might significantly improve user experience of your
app for users who are participating in huge groups.
Parameters: See GroupsQuery
*/
func (c *Client) IndexGroups(req *GroupsQuery) ([]*Group, error) {
httpReq, err := http.NewRequest("GET", c.endpointBase+indexGroupsEndpoint, nil)
if err != nil {
return nil, err
}
URL := httpReq.URL
query := URL.Query()
if req != nil {
if req.Page != 0 {
query.Set("page", strconv.Itoa(req.Page))
}
if req.PerPage != 0 {
query.Set("per_page", strconv.Itoa(req.PerPage))
}
if req.Omit != "" {
query.Set("omit", req.Omit)
}
}
URL.RawQuery = query.Encode()
var resp []*Group
err = c.do(httpReq, &resp)
if err != nil {
return nil, err
}
return resp, nil
}
///// Former /////
/*
FormerGroups -
List they groups you have left but can rejoin.
*/
func (c *Client) FormerGroups() ([]*Group, error) {
httpReq, err := http.NewRequest("GET", c.endpointBase+formerGroupsEndpoint, nil)
if err != nil {
return nil, err
}
var resp []*Group
err = c.do(httpReq, &resp)
if err != nil {
return nil, err
}
return resp, nil
}
///// Show /////
/*
ShowGroup -
Loads a specific group.
Parameters:
groupID - required, ID(string)
*/
func (c *Client) ShowGroup(groupID ID) (*Group, error) {
URL := fmt.Sprintf(c.endpointBase+showGroupEndpoint, groupID)
httpReq, err := http.NewRequest("GET", URL, nil)
if err != nil {
return nil, err
}
var resp Group
err = c.do(httpReq, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}
///// Create /////
/*
CreateGroup -
Create a new group
Parameters: See GroupSettings
*/
func (c *Client) CreateGroup(gs GroupSettings) (*Group, error) {
httpReq, err := http.NewRequest("POST", c.endpointBase+createGroupEndpoint, nil)
if err != nil {
return nil, err
}
data := url.Values{}
if gs.Name == "" {
return nil, fmt.Errorf("GroupsCreateRequest Name field is required")
}
data.Set("name", gs.Name)
if gs.Description != "" {
data.Set("description", gs.Description)
}
if gs.ImageURL != "" {
data.Set("image_url", gs.ImageURL)
}
if gs.Share {
data.Set("share", "true")
}
httpReq.PostForm = data
var resp Group
err = c.do(httpReq, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}
///// Update /////
/*
UpdateGroup -
Update a group after creation
Parameters:
groupID - required, ID(string)
See GroupSettings
*/
func (c *Client) UpdateGroup(groupID ID, gs GroupSettings) (*Group, error) {
URL := fmt.Sprintf(c.endpointBase+updateGroupEndpoint, groupID)
httpReq, err := http.NewRequest("POST", URL, nil)
if err != nil {
return nil, err
}
data := url.Values{}
if gs.Name != "" {
data.Set("name", gs.Name)
}
if gs.Description != "" {
data.Set("description", gs.Description)
}
if gs.ImageURL != "" {
data.Set("image_url", gs.ImageURL)
}
if gs.OfficeMode {
data.Set("office_mode", "true")
}
if gs.Share {
data.Set("share", "true")
}
httpReq.PostForm = data
var resp Group
err = c.do(httpReq, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}
///// Destroy /////
/*
DestroyGroup -
Disband a group
This action is only available to the group creator
Parameters:
groupID - required, ID(string)
*/
func (c *Client) DestroyGroup(groupID ID) error {
url := fmt.Sprintf(c.endpointBase+destroyGroupEndpoint, groupID)
httpReq, err := http.NewRequest("POST", url, nil)
if err != nil {
return err
}
return c.do(httpReq, nil)
}
///// Join /////
/*
JoinGroup -
Join a shared group
Parameters:
groupID - required, ID(string)
shareToken - required, string
*/
func (c *Client) JoinGroup(groupID ID, shareToken string) (*Group, error) {
URL := fmt.Sprintf(c.endpointBase+joinGroupEndpoint, groupID, shareToken)
httpReq, err := http.NewRequest("POST", URL, nil)
if err != nil {
return nil, err
}
var resp Group
err = c.do(httpReq, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}
///// Rejoin /////
/*
RejoinGroup -
Rejoin a group. Only works if you previously removed yourself.
Parameters:
groupID - required, ID(string)
*/
func (c *Client) RejoinGroup(groupID ID) (*Group, error) {
httpReq, err := http.NewRequest("POST", c.endpointBase+rejoinGroupEndpoint, nil)
if err != nil {
return nil, err
}
data := url.Values{}
data.Set("group_id", string(groupID))
httpReq.PostForm = data
var resp Group
err = c.do(httpReq, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}
///// Change Owner /////
/*
ChangeGroupOwner-
Change owner of requested groups.
This action is only available to the group creator.
Response is a result object which contain status field,
the result of change owner action for the request
Parameters: See ChangeOwnerRequest
*/
func (c *Client) ChangeGroupOwner(reqs ChangeOwnerRequest) (ChangeOwnerResult, error) {
httpReq, err := http.NewRequest("POST", c.endpointBase+changeGroupOwnerEndpoint, nil)
if err != nil {
return ChangeOwnerResult{}, err
}
data := url.Values{}
data.Set("requests", marshal([]ChangeOwnerRequest{reqs}))
httpReq.PostForm = data
var resp struct {
Results []ChangeOwnerResult `json:"results"`
}
err = c.do(httpReq, &resp)
if err != nil {
return ChangeOwnerResult{}, err
}
if len(resp.Results) < 1 {
return ChangeOwnerResult{}, errors.New("failed to parse results")
}
return resp.Results[0], nil
}
type ChangeOwnerStatusCode string
const (
ChangeOwner_Ok ChangeOwnerStatusCode = "200"
ChangeOwner_RequesterNewOwner ChangeOwnerStatusCode = "400"
ChangeOwner_NotOwner ChangeOwnerStatusCode = "403"
ChangeOwner_BadGroupOrOwner ChangeOwnerStatusCode = "404"
ChangeOwner_BadRequest ChangeOwnerStatusCode = "405"
)
// String returns the description of the status code according to GroupMe
func (c ChangeOwnerStatusCode) String() string {
return map[ChangeOwnerStatusCode]string{
ChangeOwner_Ok: "success",
ChangeOwner_RequesterNewOwner: "requester is also a new owner",
ChangeOwner_NotOwner: "requester is not the owner of the group",
ChangeOwner_BadGroupOrOwner: "group or new owner not found or new owner is not memeber of the group",
ChangeOwner_BadRequest: "request object is missing required field or any of the required fields is not an ID",
}[c]
}
// ChangeOwnerRequest defines the new owner of a group
type ChangeOwnerRequest struct {
// Required
GroupID string `json:"group_id"`
// Required. UserId of the new owner of the group
// who must be an active member of the group
OwnerID string `json:"owner_id"`
}
func (r ChangeOwnerRequest) String() string {
return marshal(&r)
}
// ChangeOwnerResult holds the status of the group owner change
type ChangeOwnerResult struct {
GroupID string `json:"group_id"`
// UserId of the new owner of the group who is
// an active member of the group
OwnerID string `json:"owner_id"`
Status ChangeOwnerStatusCode `json:"status"`
}
func (r ChangeOwnerResult) String() string {
return marshal(&r)
}

698
groups_api_test.go Executable file
View File

@ -0,0 +1,698 @@
package groupme
import (
"fmt"
"net/http"
"testing"
"github.com/gorilla/mux"
"github.com/stretchr/testify/suite"
)
type GroupsAPISuite struct{ APISuite }
func (s *GroupsAPISuite) SetupSuite() {
s.handler = groupsTestRouter()
s.setupSuite()
}
func (s *GroupsAPISuite) TestGroupsIndex() {
groups, err := s.client.IndexGroups(&GroupsQuery{
Page: 5,
PerPage: 20,
Omit: "memberships",
})
s.Require().NoError(err)
s.Require().NotZero(groups)
for _, group := range groups {
s.Assert().NotZero(group)
}
}
func (s *GroupsAPISuite) TestGroupsFormer() {
groups, err := s.client.FormerGroups()
s.Require().NoError(err)
s.Require().NotZero(groups)
for _, group := range groups {
s.Assert().NotZero(group)
}
}
func (s *GroupsAPISuite) TestGroupsShow() {
group, err := s.client.ShowGroup("1")
s.Require().NoError(err)
s.Assert().NotZero(group)
}
func (s *GroupsAPISuite) TestGroupsCreate() {
group, err := s.client.CreateGroup(GroupSettings{
"Test",
"This is a test group",
"www.blank.com/image",
false,
true,
})
s.Require().NoError(err)
s.Assert().NotZero(group)
}
func (s *GroupsAPISuite) TestGroupsCreate_EmptyName() {
group, err := s.client.CreateGroup(GroupSettings{
Name: "",
})
s.Require().Error(err)
s.Assert().Zero(group)
}
func (s *GroupsAPISuite) TestGroupsUpdate() {
group, err := s.client.UpdateGroup("1", GroupSettings{
"Test",
"This is a test group",
"www.blank.com/image",
true,
true,
})
s.Require().NoError(err)
s.Assert().NotZero(group)
}
func (s *GroupsAPISuite) TestGroupsDestroy() {
err := s.client.DestroyGroup("1")
s.Require().NoError(err)
}
func (s *GroupsAPISuite) TestGroupsJoin() {
group, err := s.client.JoinGroup("1", "please")
s.Require().NoError(err)
s.Assert().NotZero(group)
}
func (s *GroupsAPISuite) TestGroupsRejoin() {
group, err := s.client.RejoinGroup("1")
s.Require().NoError(err)
s.Assert().NotZero(group)
}
func (s *GroupsAPISuite) TestGroupsChangeOwner() {
result, err := s.client.ChangeGroupOwner(
ChangeOwnerRequest{
"1",
"123",
},
)
s.Require().NoError(err)
s.Assert().NotZero(result)
}
func TestGroupsAPISuite(t *testing.T) {
suite.Run(t, new(GroupsAPISuite))
}
////////// Test Groups Router //////////
func groupsTestRouter() *mux.Router {
router := mux.NewRouter()
// Index
router.Path("/groups").
Methods("GET").
Name("IndexGroups").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
fmt.Fprint(w, `{
"response": [
{
"id": "1234567890",
"name": "Family",
"type": "private",
"description": "Coolest Family Ever",
"image_url": "https://i.groupme.com/123456789",
"creator_user_id": "1234567890",
"created_at": 1302623328,
"updated_at": 1302623328,
"members": [
{
"user_id": "1234567890",
"nickname": "Jane",
"muted": false,
"image_url": "https://i.groupme.com/123456789"
}
],
"share_url": "https://groupme.com/join_group/1234567890/SHARE_TOKEN",
"messages": {
"count": 100,
"last_message_id": "1234567890",
"last_message_created_at": 1302623328,
"preview": {
"nickname": "Jane",
"text": "Hello world",
"image_url": "https://i.groupme.com/123456789",
"attachments": [
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "location",
"lat": "40.738206",
"lng": "-73.993285",
"name": "GroupMe HQ"
},
{
"type": "split",
"token": "SPLIT_TOKEN"
},
{
"type": "emoji",
"placeholder": "☃",
"charmap": [
[
1,
42
],
[
2,
34
]
]
}
]
}
}
}
],
"meta": {
"code": 201,
"errors": []
}
}`)
})
// Former
router.Path("/groups/former").
Methods("GET").
Name("FormerGroups").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
fmt.Fprint(w, `{
"response": [
{
"id": "1234567890",
"name": "Family",
"type": "private",
"description": "Coolest Family Ever",
"image_url": "https://i.groupme.com/123456789",
"creator_user_id": "1234567890",
"created_at": 1302623328,
"updated_at": 1302623328,
"members": [
{
"user_id": "1234567890",
"nickname": "Jane",
"muted": false,
"image_url": "https://i.groupme.com/123456789"
}
],
"share_url": "https://groupme.com/join_group/1234567890/SHARE_TOKEN",
"messages": {
"count": 100,
"last_message_id": "1234567890",
"last_message_created_at": 1302623328,
"preview": {
"nickname": "Jane",
"text": "Hello world",
"image_url": "https://i.groupme.com/123456789",
"attachments": [
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "location",
"lat": "40.738206",
"lng": "-73.993285",
"name": "GroupMe HQ"
},
{
"type": "split",
"token": "SPLIT_TOKEN"
},
{
"type": "emoji",
"placeholder": "☃",
"charmap": [
[
1,
42
],
[
2,
34
]
]
}
]
}
}
}
],
"meta": {
"code": 200,
"errors": []
}
}`)
})
// Show
router.Path("/groups/{id:[0-9]+}").
Methods("GET").
Name("ShowGroup").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
fmt.Fprint(w, `{
"response": {
"id": "1234567890",
"name": "Family",
"type": "private",
"description": "Coolest Family Ever",
"image_url": "https://i.groupme.com/123456789",
"creator_user_id": "1234567890",
"created_at": 1302623328,
"updated_at": 1302623328,
"members": [
{
"user_id": "1234567890",
"nickname": "Jane",
"muted": false,
"image_url": "https://i.groupme.com/123456789"
}
],
"share_url": "https://groupme.com/join_group/1234567890/SHARE_TOKEN",
"messages": {
"count": 100,
"last_message_id": "1234567890",
"last_message_created_at": 1302623328,
"preview": {
"nickname": "Jane",
"text": "Hello world",
"image_url": "https://i.groupme.com/123456789",
"attachments": [
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "location",
"lat": "40.738206",
"lng": "-73.993285",
"name": "GroupMe HQ"
},
{
"type": "split",
"token": "SPLIT_TOKEN"
},
{
"type": "emoji",
"placeholder": "☃",
"charmap": [
[
1,
42
],
[
2,
34
]
]
}
]
}
}
},
"meta": {
"code": 200,
"errors": []
}
}`)
})
// Create
router.Path("/groups").
Methods("POST").
Name("CreateGroup").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(201)
fmt.Fprint(w, `{
"response": {
"id": "1234567890",
"name": "Family",
"type": "private",
"description": "Coolest Family Ever",
"image_url": "https://i.groupme.com/123456789",
"creator_user_id": "1234567890",
"created_at": 1302623328,
"updated_at": 1302623328,
"members": [
{
"user_id": "1234567890",
"nickname": "Jane",
"muted": false,
"image_url": "https://i.groupme.com/123456789"
}
],
"share_url": "https://groupme.com/join_group/1234567890/SHARE_TOKEN",
"messages": {
"count": 100,
"last_message_id": "1234567890",
"last_message_created_at": 1302623328,
"preview": {
"nickname": "Jane",
"text": "Hello world",
"image_url": "https://i.groupme.com/123456789",
"attachments": [
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "location",
"lat": "40.738206",
"lng": "-73.993285",
"name": "GroupMe HQ"
},
{
"type": "split",
"token": "SPLIT_TOKEN"
},
{
"type": "emoji",
"placeholder": "☃",
"charmap": [
[
1,
42
],
[
2,
34
]
]
}
]
}
}
},
"meta": {
"code": 201,
"errors": []
}
}`)
})
// Update
router.Path("/groups/{id:[0-9]+}/update").
Methods("POST").
Name("UpdateGroup").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
fmt.Fprint(w, `{
"response": {
"id": "1234567890",
"name": "Family",
"type": "private",
"description": "Coolest Family Ever",
"image_url": "https://i.groupme.com/123456789",
"creator_user_id": "1234567890",
"created_at": 1302623328,
"updated_at": 1302623328,
"members": [
{
"user_id": "1234567890",
"nickname": "Jane",
"muted": false,
"image_url": "https://i.groupme.com/123456789"
}
],
"share_url": "https://groupme.com/join_group/1234567890/SHARE_TOKEN",
"messages": {
"count": 100,
"last_message_id": "1234567890",
"last_message_created_at": 1302623328,
"preview": {
"nickname": "Jane",
"text": "Hello world",
"image_url": "https://i.groupme.com/123456789",
"attachments": [
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "location",
"lat": "40.738206",
"lng": "-73.993285",
"name": "GroupMe HQ"
},
{
"type": "split",
"token": "SPLIT_TOKEN"
},
{
"type": "emoji",
"placeholder": "☃",
"charmap": [
[
1,
42
],
[
2,
34
]
]
}
]
}
}
},
"meta": {
"code": 201,
"errors": []
}
}`)
})
// Destroy
router.Path("/groups/{id:[0-9]+}/destroy").
Methods("POST").
Name("DestroyGroup").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
})
// Join
router.Path("/groups/{id:[0-9]+}/join/{share_token}").
Methods("POST").
Name("JoinGroup").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
fmt.Fprint(w, `{
"response": {
"group": {
"id": "1234567890",
"name": "Family",
"type": "private",
"description": "Coolest Family Ever",
"image_url": "https://i.groupme.com/123456789",
"creator_user_id": "1234567890",
"created_at": 1302623328,
"updated_at": 1302623328,
"members": [
{
"user_id": "1234567890",
"nickname": "Jane",
"muted": false,
"image_url": "https://i.groupme.com/123456789"
}
],
"share_url": "https://groupme.com/join_group/1234567890/SHARE_TOKEN",
"messages": {
"count": 100,
"last_message_id": "1234567890",
"last_message_created_at": 1302623328,
"preview": {
"nickname": "Jane",
"text": "Hello world",
"image_url": "https://i.groupme.com/123456789",
"attachments": [
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "location",
"lat": "40.738206",
"lng": "-73.993285",
"name": "GroupMe HQ"
},
{
"type": "split",
"token": "SPLIT_TOKEN"
},
{
"type": "emoji",
"placeholder": "☃",
"charmap": [
[
1,
42
],
[
2,
34
]
]
}
]
}
}
}
},
"meta": {
"code": 201,
"errors": []
}
}`)
})
// Rejoin
router.Path("/groups/join").
Methods("POST").
Name("RejoinGroup").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
fmt.Fprint(w, `{
"response": {
"id": "1234567890",
"name": "Family",
"type": "private",
"description": "Coolest Family Ever",
"image_url": "https://i.groupme.com/123456789",
"creator_user_id": "1234567890",
"created_at": 1302623328,
"updated_at": 1302623328,
"members": [
{
"user_id": "1234567890",
"nickname": "Jane",
"muted": false,
"image_url": "https://i.groupme.com/123456789"
}
],
"share_url": "https://groupme.com/join_group/1234567890/SHARE_TOKEN",
"messages": {
"count": 100,
"last_message_id": "1234567890",
"last_message_created_at": 1302623328,
"preview": {
"nickname": "Jane",
"text": "Hello world",
"image_url": "https://i.groupme.com/123456789",
"attachments": [
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "location",
"lat": "40.738206",
"lng": "-73.993285",
"name": "GroupMe HQ"
},
{
"type": "split",
"token": "SPLIT_TOKEN"
},
{
"type": "emoji",
"placeholder": "☃",
"charmap": [
[
1,
42
],
[
2,
34
]
]
}
]
}
}
},
"meta": {
"code": 201,
"errors": []
}
}`)
})
// Change Owner
router.Path("/groups/change_owners").
Methods("POST").
Name("ChangeGroupOwner").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
fmt.Fprint(w, `{
"response": {
"results": [
{
"group_id": "1234567890",
"owner_id": "1234567890",
"status": "200"
},
{
"group_id": "1234567890",
"owner_id": "1234567890",
"status": "400"
}
]
},
"meta": {
"code": 201,
"errors": []
}
}`)
})
/*// Return test router //*/
return router
}

223
json.go Executable file
View File

@ -0,0 +1,223 @@
package groupme
import (
"encoding/json"
"fmt"
)
// Meta is the error type returned in the GroupMe response.
// Meant for clients that can't read HTTP status codes
type Meta struct {
Code HTTPStatusCode `json:"code"`
Errors []string `json:"errors"`
}
// Error returns the code and the error list as a string.
// Satisfies the error interface
func (m Meta) Error() string {
return fmt.Sprintf("Error Code %d: %v", m.Code, m.Errors)
}
// Group is a GroupMe group, returned in JSON API responses
type Group struct {
ID ID `json:"id"`
Name string `json:"name"`
// Type of group (private|public)
Type string `json:"type"`
Description string `json:"description"`
ImageURL string `json:"image_url"`
CreatorUserID ID `json:"creator_user_id"`
CreatedAt Timestamp `json:"created_at"`
UpdatedAt Timestamp `json:"updated_at"`
Members []*Member `json:"members"`
ShareURL string `json:"share_url"`
Messages GroupMessages `json:"messages"`
}
// GroupMessages is a Group field, only returned in Group JSON API responses
type GroupMessages struct {
Count uint `json:"count"`
LastMessageID ID `json:"last_message_id"`
LastMessageCreatedAt Timestamp `json:"last_message_created_at"`
Preview MessagePreview `json:"preview"`
}
// MessagePreview is a GroupMessages field, only returned in Group JSON API responses.
// Abbreviated form of Message type
type MessagePreview struct {
Nickname string `json:"nickname"`
Text string `json:"text"`
ImageURL string `json:"image_url"`
Attachments []*Attachment `json:"attachments"`
}
// GetMemberByUserID gets the group member by their UserID,
// nil if no member matches
func (g Group) GetMemberByUserID(userID ID) *Member {
for _, member := range g.Members {
if member.UserID == userID {
return member
}
}
return nil
}
// GetMemberByNickname gets the group member by their Nickname,
// nil if no member matches
func (g Group) GetMemberByNickname(nickname string) *Member {
for _, member := range g.Members {
if member.Nickname == nickname {
return member
}
}
return nil
}
func (g Group) String() string {
return marshal(&g)
}
// Member is a GroupMe group member, returned in JSON API responses
type Member struct {
ID ID `json:"id"`
UserID ID `json:"user_id"`
Nickname string `json:"nickname"`
Muted bool `json:"muted"`
ImageURL string `json:"image_url"`
AutoKicked bool `json:"autokicked"`
AppInstalled bool `json:"app_installed"`
GUID string `json:"guid"`
}
func (m Member) String() string {
return marshal(&m)
}
// Message is a GroupMe group message, returned in JSON API responses
type Message struct {
ID ID `json:"id"`
SourceGUID string `json:"source_guid"`
CreatedAt Timestamp `json:"created_at"`
GroupID ID `json:"group_id"`
UserID ID `json:"user_id"`
BotID ID `json:"bot_id"`
SenderID ID `json:"sender_id"`
SenderType SenderType `json:"sender_type"`
System bool `json:"system"`
Name string `json:"name"`
RecipientID ID `json:"recipient_id"`
ConversationID ID `json:"conversation_id"`
AvatarURL string `json:"avatar_url"`
// Maximum length of 1000 characters
Text string `json:"text"`
// Must be an image service URL (i.groupme.com)
ImageURL string `json:"image_url"`
FavoritedBy []string `json:"favorited_by"`
Attachments []*Attachment `json:"attachments"`
}
func (m Message) String() string {
return marshal(&m)
}
type SenderType string
// SenderType constants
const (
SenderType_User SenderType = "user"
SenderType_Bot SenderType = "bot"
SenderType_System SenderType = "system"
)
type AttachmentType string
// AttachmentType constants
const (
Mentions AttachmentType = "mentions"
Image AttachmentType = "image"
Location AttachmentType = "location"
Emoji AttachmentType = "emoji"
)
// Attachment is a GroupMe message attachment, returned in JSON API responses
type Attachment struct {
Type AttachmentType `json:"type"`
Loci [][]int `json:"loci"`
UserIDs []ID `json:"user_ids"`
URL string `json:"url"`
Name string `json:"name"`
Latitude string `json:"lat"`
Longitude string `json:"lng"`
Placeholder string `json:"placeholder"`
Charmap [][]int `json:"charmap"`
}
func (a Attachment) String() string {
return marshal(&a)
}
// User is a GroupMe user, returned in JSON API responses
type User struct {
ID ID `json:"id"`
PhoneNumber PhoneNumber `json:"phone_number"`
ImageURL string `json:"image_url"`
Name string `json:"name"`
CreatedAt Timestamp `json:"created_at"`
UpdatedAt Timestamp `json:"updated_at"`
AvatarURL string `json:"avatar_url"`
Email string `json:"email"`
SMS bool `json:"sms"`
}
func (u User) String() string {
return marshal(&u)
}
// Chat is a GroupMe direct message conversation between two users,
// returned in JSON API responses
type Chat struct {
CreatedAt Timestamp `json:"created_at"`
UpdatedAt Timestamp `json:"updated_at"`
LastMessage *Message `json:"last_message"`
MessagesCount int `json:"messages_count"`
OtherUser User `json:"other_user"`
}
func (c Chat) String() string {
return marshal(&c)
}
type Bot struct {
BotID ID `json:"bot_id"`
GroupID ID `json:"group_id"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
CallbackURL string `json:"callback_url"`
DMNotification bool `json:"dm_notification"`
}
func (b Bot) String() string {
return marshal(&b)
}
type Block struct {
UserID ID `json:"user_id"`
BlockedUserID ID `json:"blocked_user_id"`
CreatedAT Timestamp `json:"created_at"`
}
func (b Block) String() string {
return marshal(&b)
}
// Superficially increases test coverage
func marshal(i interface{}) string {
bytes, err := json.MarshalIndent(i, "", "\t")
if err != nil {
return ""
}
return string(bytes)
}

74
json_test.go Executable file
View File

@ -0,0 +1,74 @@
package groupme
import (
"testing"
"github.com/stretchr/testify/suite"
)
type JSONSuite struct {
suite.Suite
}
func (s *JSONSuite) TestGroup_GetMemberByUserID_Match() {
m := Member{
UserID: "123",
}
g := Group{
Members: []*Member{&m},
}
actual := g.GetMemberByUserID("123")
s.Require().NotNil(actual)
s.Assert().Equal(m, *actual)
}
func (s *JSONSuite) TestGroup_GetMemberByUserID_NoMatch() {
g := Group{
Members: []*Member{},
}
actual := g.GetMemberByUserID("123")
s.Require().Nil(actual)
}
func (s *JSONSuite) TestGroup_GetMemberByNickname_Match() {
m := Member{
Nickname: "Test User",
}
g := Group{
Members: []*Member{&m},
}
actual := g.GetMemberByNickname("Test User")
s.Require().NotNil(actual)
s.Assert().Equal(m, *actual)
}
func (s *JSONSuite) TestGroup_GetMemberByNickname_NoMatch() {
g := Group{
Members: []*Member{},
}
actual := g.GetMemberByNickname("Test User")
s.Require().Nil(actual)
}
func (s *JSONSuite) TestMarshal_NoError() {
s.Assert().Equal("{}", marshal(&struct{}{}))
}
func (s *JSONSuite) TestMarshal_Error() {
var c chan struct{}
s.Assert().Equal("", marshal(c))
}
func TestJSONSuite(t *testing.T) {
suite.Run(t, new(JSONSuite))
}

129
leaderboard_api.go Normal file
View File

@ -0,0 +1,129 @@
package groupme
import (
"fmt"
"net/http"
)
// GroupMe documentation: https://dev.groupme.com/docs/v3#leaderboard
////////// Endpoints //////////
const (
// Used to build other endpoints
leaderboardEndpointRoot = groupEndpointRoot + "/likes"
// Actual Endpoints
indexLeaderboardEndpoint = leaderboardEndpointRoot // GET
myLikesLeaderboardEndpoint = leaderboardEndpointRoot + "/mine" // GET
myHitsLeaderboardEndpoint = leaderboardEndpointRoot + "/for_me" // GET
)
////////// API Requests //////////
// Index
type period string
func (p period) String() string {
return string(p)
}
// Define acceptable period values
const (
Period_Day = "day"
Period_Week = "week"
Period_Month = "month"
)
/*
IndexLeaderboard -
A list of the liked messages in the group for a given period of
time. Messages are ranked in order of number of likes.
Parameters:
groupID - required, ID(string)
p - required, period(string)
*/
func (c *Client) IndexLeaderboard(groupID ID, p period) ([]*Message, error) {
url := fmt.Sprintf(c.endpointBase+indexLeaderboardEndpoint, groupID)
httpReq, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
URL := httpReq.URL
query := URL.Query()
query.Set("period", p.String())
URL.RawQuery = query.Encode()
var resp struct {
Messages []*Message `json:"messages"`
}
err = c.do(httpReq, &resp)
if err != nil {
return nil, err
}
return resp.Messages, nil
}
// My Likes
/*
MyLikesLeaderboard -
A list of messages you have liked. Messages are returned in
reverse chrono-order. Note that the payload includes a liked_at
timestamp in ISO-8601 format.
Parameters:
groupID - required, ID(string)
*/
func (c *Client) MyLikesLeaderboard(groupID ID) ([]*Message, error) {
url := fmt.Sprintf(c.endpointBase+myLikesLeaderboardEndpoint, groupID)
httpReq, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
var resp struct {
Messages []*Message `json:"messages"`
}
err = c.do(httpReq, &resp)
if err != nil {
return nil, err
}
return resp.Messages, nil
}
// My Hits
/*
MyHitsLeaderboard -
A list of messages you have liked. Messages are returned in
reverse chrono-order. Note that the payload includes a liked_at
timestamp in ISO-8601 format.
Parameters:
groupID - required, ID(string)
*/
func (c *Client) MyHitsLeaderboard(groupID ID) ([]*Message, error) {
url := fmt.Sprintf(c.endpointBase+myHitsLeaderboardEndpoint, groupID)
httpReq, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
var resp struct {
Messages []*Message `json:"messages"`
}
err = c.do(httpReq, &resp)
if err != nil {
return nil, err
}
return resp.Messages, nil
}

310
leaderboard_api_test.go Normal file
View File

@ -0,0 +1,310 @@
package groupme
import (
"fmt"
"net/http"
"testing"
"github.com/gorilla/mux"
"github.com/stretchr/testify/suite"
)
type LeaderboardAPISuite struct{ APISuite }
func (s *LeaderboardAPISuite) SetupSuite() {
s.handler = leaderboardTestRouter()
s.setupSuite()
}
func (s *LeaderboardAPISuite) TestLeaderboardIndex() {
messages, err := s.client.IndexLeaderboard("1", Period_Day)
s.Require().NoError(err)
s.Require().NotZero(messages)
for _, message := range messages {
s.Assert().NotZero(message)
}
}
func (s *LeaderboardAPISuite) TestLeaderboardMyLikes() {
messages, err := s.client.MyLikesLeaderboard("1")
s.Require().NoError(err)
s.Require().NotZero(messages)
for _, message := range messages {
s.Assert().NotZero(message)
}
}
func (s *LeaderboardAPISuite) TestLeaderboardMyHits() {
messages, err := s.client.MyHitsLeaderboard("1")
s.Require().NoError(err)
s.Require().NotZero(messages)
for _, message := range messages {
s.Assert().NotZero(message)
}
}
func TestLeaderboardAPISuite(t *testing.T) {
suite.Run(t, new(LeaderboardAPISuite))
}
func leaderboardTestRouter() *mux.Router {
router := mux.NewRouter()
// Index
router.Path("/groups/{id:[0-9]+}/likes").
Queries("period", "{period:day|week|month}").
Methods("GET").
Name("IndexLeaderboard").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
fmt.Fprint(w, `{
"response": {
"messages": [
{
"id": "1234567890",
"source_guid": "GUID",
"created_at": 1302623328,
"user_id": "1234567890",
"group_id": "1234567890",
"name": "John",
"avatar_url": "https://i.groupme.com/123456789",
"text": "Hello world ☃☃",
"system": true,
"favorited_by": [
"101",
"66",
"1234567890"
],
"attachments": [
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "location",
"lat": "40.738206",
"lng": "-73.993285",
"name": "GroupMe HQ"
},
{
"type": "split",
"token": "SPLIT_TOKEN"
},
{
"type": "emoji",
"placeholder": "☃",
"charmap": [
[
1,
42
],
[
2,
34
]
]
}
]
},
{
"id": "1234567890",
"source_guid": "GUID",
"created_at": 1302623328,
"user_id": "1234567890",
"group_id": "1234567890",
"name": "John",
"avatar_url": "https://i.groupme.com/123456789",
"text": "Hello world ☃☃",
"system": true,
"favorited_by": [
"1",
"2"
],
"attachments": [
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "location",
"lat": "40.738206",
"lng": "-73.993285",
"name": "GroupMe HQ"
},
{
"type": "split",
"token": "SPLIT_TOKEN"
},
{
"type": "emoji",
"placeholder": "☃",
"charmap": [
[
1,
42
],
[
2,
34
]
]
}
]
}
]
},
"meta": {
"code": 200,
"errors": []
}
}`)
})
// My Likes
router.Path("/groups/{id:[0-9]+}/likes/mine").
Methods("GET").
Name("MyLikesLeaderboard").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
fmt.Fprint(w, `{
"response": {
"messages": [
{
"id": "1234567890",
"source_guid": "GUID",
"created_at": 1302623328,
"user_id": "1234567890",
"group_id": "1234567890",
"name": "John",
"avatar_url": "https://i.groupme.com/123456789",
"text": "Hello world ☃☃",
"system": true,
"favorited_by": [
"101",
"66",
"1234567890"
],
"attachments": [
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "location",
"lat": "40.738206",
"lng": "-73.993285",
"name": "GroupMe HQ"
},
{
"type": "split",
"token": "SPLIT_TOKEN"
},
{
"type": "emoji",
"placeholder": "☃",
"charmap": [
[
1,
42
],
[
2,
34
]
]
}
],
"liked_at": "2014-05-08T18:30:31.6617Z"
}
]
},
"meta": {
"code": 200,
"errors": []
}
}`)
})
// My Hits
router.Path("/groups/{id:[0-9]+}/likes/for_me").
Methods("GET").
Name("MyHitsLeaderboard").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
fmt.Fprint(w, `{
"response": {
"messages": [
{
"id": "1234567890",
"source_guid": "GUID",
"created_at": 1302623328,
"user_id": "1234567890",
"group_id": "1234567890",
"name": "John",
"avatar_url": "https://i.groupme.com/123456789",
"text": "Hello world ☃☃",
"system": true,
"favorited_by": [
"101",
"66",
"1234567890"
],
"attachments": [
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "location",
"lat": "40.738206",
"lng": "-73.993285",
"name": "GroupMe HQ"
},
{
"type": "split",
"token": "SPLIT_TOKEN"
},
{
"type": "emoji",
"placeholder": "☃",
"charmap": [
[
1,
42
],
[
2,
34
]
]
}
]
}
]
},
"meta": {
"code": 200,
"errors": []
}
}`)
})
/*// Return test router //*/
return router
}

63
likes_api.go Normal file
View File

@ -0,0 +1,63 @@
package groupme
import (
"fmt"
"net/http"
)
// GroupMe documentation: https://dev.groupme.com/docs/v3#likes
////////// Endpoints //////////
const (
// Used to build other endpoints
likesEndpointRoot = "/messages/%s/%s"
createLikeEndpoint = likesEndpointRoot + "/like" // POST
destroyLikeEndpoint = likesEndpointRoot + "/unlike" // POST
)
////////// API Requests /////////
// Create
/*
CreateLike -
Like a message.
Parameters:
conversationID - required, ID(string)
messageID - required, ID(string)
*/
func (c *Client) CreateLike(conversationID, messageID ID) error {
url := fmt.Sprintf(c.endpointBase+createLikeEndpoint, conversationID, messageID)
httpReq, err := http.NewRequest("POST", url, nil)
if err != nil {
return err
}
return c.do(httpReq, nil)
}
// Destroy
/*
DestroyLike -
Unlike a message.
Parameters:
conversationID - required, ID(string)
messageID - required, ID(string)
*/
func (c *Client) DestroyLike(conversationID, messageID ID) error {
url := fmt.Sprintf(c.endpointBase+destroyLikeEndpoint, conversationID, messageID)
httpReq, err := http.NewRequest("POST", url, nil)
if err != nil {
return err
}
return c.do(httpReq, nil)
}

52
likes_api_test.go Normal file
View File

@ -0,0 +1,52 @@
package groupme
import (
"net/http"
"testing"
"github.com/gorilla/mux"
"github.com/stretchr/testify/suite"
)
type LikesAPISuite struct{ APISuite }
func (s *LikesAPISuite) SetupSuite() {
s.handler = likesTestRouter()
s.setupSuite()
}
func (s *LikesAPISuite) TestLikesCreate() {
err := s.client.CreateLike("1", "1")
s.Require().NoError(err)
}
func (s *LikesAPISuite) TestLikesDestroy() {
err := s.client.DestroyLike("1", "1")
s.Require().NoError(err)
}
func TestLikesAPISuite(t *testing.T) {
suite.Run(t, new(LikesAPISuite))
}
func likesTestRouter() *mux.Router {
router := mux.NewRouter()
// Create
router.Path(`/messages/{conversation_id}/{message_id}/like`).
Methods("POST").
Name("CreateLike").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
})
// Destroy
router.Path(`/messages/{conversation_id}/{message_id}/unlike`).
Methods("POST").
Name("DestroyLike").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
})
/*// Return test router //*/
return router
}

85
main_test.go Executable file
View File

@ -0,0 +1,85 @@
package groupme
import (
"fmt"
"log"
"math/rand"
"net/http"
"os"
"strconv"
"sync"
"testing"
"time"
"github.com/stretchr/testify/suite"
)
////////// Base API Suite //////////
type APISuite struct {
// Base attributes
suite.Suite
client *Client
server *http.Server
wg sync.WaitGroup
// Overriden by child Suite
addr string
handler http.Handler
}
func (s *APISuite) setupSuite() {
s.addr = "localhost:" + s.generatePort()
s.client = NewClient("")
s.client.endpointBase = "http://" + s.addr
s.server = s.startServer(s.addr, s.handler)
}
func (s *APISuite) TearDownSuite() {
s.client.Close()
s.server.Close()
s.wg.Wait()
}
///// Start Server /////
func (s *APISuite) startServer(addr string, handler http.Handler) *http.Server {
server := &http.Server{
Addr: addr,
Handler: handler,
ErrorLog: log.New(os.Stdout, "SERVER", log.Ltime),
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
if err := server.ListenAndServe(); err.Error() != "http: Server closed" {
s.Assert().NoError(err)
}
}()
// Wait until server has started listening
url := fmt.Sprintf("http://%s", addr)
for _, err := http.Get(url); err != nil; _, err = http.Get(url) {
continue
}
return server
}
///// Generate Ephemeral Port /////
const (
portMin = 49152
portMax = 65535
portRange = portMax - portMin
)
func (s *APISuite) generatePort() string {
rand.Seed(time.Now().UnixNano())
return strconv.Itoa((rand.Intn(portRange) + portMin))
}
////////// Test Main //////////
func TestMain(m *testing.M) {
os.Exit(m.Run())
}

174
members_api.go Normal file
View File

@ -0,0 +1,174 @@
package groupme
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
)
// GroupMe documentation: https://dev.groupme.com/docs/v3#members
////////// Endpoints //////////
const (
// Used to build other endpoints
membersEndpointRoot = groupEndpointRoot + "/members"
// Actual Endpoints
addMembersEndpoint = membersEndpointRoot + "/add" // POST
addMembersResultsEndpoint = membersEndpointRoot + "/results/%s" // GET
removeMemberEndpoint = membersEndpointRoot + "/%s/remove" // POST
updateMemberEndpoint = groupEndpointRoot + "/memberships/update" // POST
)
///// Add /////
/*
AddMembers -
Add members to a group.
Multiple members can be added in a single request, and results
are fetchedwith a separate call (since memberships are processed
asynchronously). The response includes a results_id that's used
in the results request.
In order to correlate request params with resulting memberships,
GUIDs can be added to the members parameters. These GUIDs will
be reflected in the membership JSON objects.
Parameters:
groupID - required, ID(string)
See Member.
Nickname - required
One of the following identifiers must be used:
UserID - ID(string)
PhoneNumber - PhoneNumber(string)
Email - string
*/
func (c *Client) AddMembers(groupID ID, members ...*Member) (string, error) {
URL := fmt.Sprintf(c.endpointBase+addMembersEndpoint, groupID)
httpReq, err := http.NewRequest("POST", URL, nil)
if err != nil {
return "", err
}
data := url.Values{}
bytes, err := json.Marshal(members)
if err != nil {
return "", err
}
data.Set("members", string(bytes))
httpReq.PostForm = data
var resp struct {
ResultsID string `json:"result_id"`
}
err = c.do(httpReq, &resp)
if err != nil {
return "", err
}
return resp.ResultsID, nil
}
///// Results /////
/*
AddMembersResults -
Get the membership results from an add call.
Successfully created memberships will be returned, including
any GUIDs that were sent up in the add request. If GUIDs were
absent, they are filled in automatically. Failed memberships
and invites are omitted.
Keep in mind that results are temporary -- they will only be
available for 1 hour after the add request.
Parameters:
groupID - required, ID(string)
resultID - required, string
*/
func (c *Client) AddMembersResults(groupID ID, resultID string) ([]*Member, error) {
URL := fmt.Sprintf(c.endpointBase+addMembersResultsEndpoint, groupID, resultID)
httpReq, err := http.NewRequest("GET", URL, nil)
if err != nil {
return nil, err
}
var resp struct {
Members []*Member `json:"members"`
}
err = c.do(httpReq, &resp)
if err != nil {
return nil, err
}
return resp.Members, nil
}
///// Remove /////
/*
RemoveMember -
Remove a member (or yourself) from a group.
Note: The creator of the group cannot be removed or exit.
Parameters:
groupID - required, ID(string)
membershipID - required, ID(string). Not the same as userID
*/
func (c *Client) RemoveMember(groupID, membershipID ID) error {
URL := fmt.Sprintf(c.endpointBase+removeMemberEndpoint, groupID, membershipID)
httpReq, err := http.NewRequest("POST", URL, nil)
if err != nil {
return err
}
return c.do(httpReq, nil)
}
///// Update /////
/*
UpdateMember -
Update your nickname in a group. The nickname must be
between 1 and 50 characters.
*/
func (c *Client) UpdateMember(groupID ID, nickname string) (*Member, error) {
URL := fmt.Sprintf(c.endpointBase+updateMemberEndpoint, groupID)
httpReq, err := http.NewRequest("POST", URL, nil)
if err != nil {
return nil, err
}
type membership struct {
Nickname string `json:"nickname"`
}
data := url.Values{}
bytes, err := json.Marshal(membership{nickname})
if err != nil {
return nil, err
}
data.Add("membership", string(bytes))
var resp Member
err = c.do(httpReq, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}

137
members_api_test.go Normal file
View File

@ -0,0 +1,137 @@
package groupme
import (
"fmt"
"net/http"
"testing"
"github.com/gorilla/mux"
"github.com/stretchr/testify/suite"
)
type MembersAPISuite struct{ APISuite }
func (s *MembersAPISuite) SetupSuite() {
s.handler = membersTestRouter()
s.setupSuite()
}
func (s *MembersAPISuite) TestMembersAdd() {
_, err := s.client.AddMembers(
"1",
&Member{Nickname: "test"},
)
s.Require().NoError(err)
}
func (s *MembersAPISuite) TestMembersResults() {
_, err := s.client.AddMembersResults("1", "123")
s.Require().NoError(err)
}
func (s *MembersAPISuite) TestMembersRemove() {
err := s.client.RemoveMember("1", "123")
s.Require().NoError(err)
}
func (s *MembersAPISuite) TestMembersUpdate() {
_, err := s.client.UpdateMember("1", "nickname")
s.Require().NoError(err)
}
func TestMembersAPISuite(t *testing.T) {
suite.Run(t, new(MembersAPISuite))
}
func membersTestRouter() *mux.Router {
router := mux.NewRouter()
// Add
router.Path("/groups/{id:[0-9]+}/members/add").
Methods("POST").
Name("AddMembers").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(202)
fmt.Fprint(w, `{
"response": {
"results_id": "GUID"
},
"meta": {
"code": 202,
"errors": []
}
}`)
})
// Results
router.Path("/groups/{id:[0-9]+}/members/results/{result_id}").
Methods("GET").
Name("AddMembersResults").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
fmt.Fprint(w, `{
"response": {
"members": [
{
"id": "1000",
"user_id": "10000",
"nickname": "John",
"muted": false,
"image_url": "https://i.groupme.com/AVATAR",
"autokicked": false,
"app_installed": true,
"guid": "GUID-1"
},
{
"id": "2000",
"user_id": "20000",
"nickname": "Anne",
"muted": false,
"image_url": "https://i.groupme.com/AVATAR",
"autokicked": false,
"app_installed": true,
"guid": "GUID-2"
}
]
},
"meta": {
"code": 200,
"errors": []
}
}`)
})
// Remove
router.Path("/groups/{id:[0-9]+}/members/{membership_id}/remove").
Methods("POST").
Name("RemoveMember").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
})
// Update
router.Path("/groups/{id:[0-9]+}/memberships/update").
Methods("POST").
Name("UpdateMember").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
fmt.Fprint(w, `{
"response": {
"id": "MEMBERSHIP ID",
"user_id": "USER ID",
"nickname": "NEW NICKNAME",
"muted": false,
"image_url": "AVATAR URL",
"autokicked": false,
"app_installed": true
},
"meta": {
"code": 200,
"errors": []
}
}`)
})
/*// Return test router //*/
return router
}

174
messages_api.go Normal file
View File

@ -0,0 +1,174 @@
package groupme
import (
"fmt"
"net/http"
"net/url"
"strconv"
"github.com/google/uuid"
)
// GroupMe documentation: https://dev.groupme.com/docs/v3#messages
////////// Endpoints //////////
const (
// Used to build other endpoints
messagesEndpointRoot = groupEndpointRoot + "/messages"
indexMessagesEndpoint = messagesEndpointRoot // GET
createMessagesEndpoint = messagesEndpointRoot // POST
)
// Index
// MessagesQuery defines the optional URL parameters for IndexMessages
type IndexMessagesQuery struct {
// Returns messages created before the given message ID
BeforeID ID
// Returns most recent messages created after the given message ID
SinceID ID
// Returns messages created immediately after the given message ID
AfterID ID
// Number of messages returned. Default is 20. Max is 100.
Limit int
}
func (q IndexMessagesQuery) String() string {
return marshal(&q)
}
// MessagesIndexResponse contains the count and set of
// messages returned by the IndexMessages API request
type IndexMessagesResponse struct {
Count int `json:"count"`
Messages []*Message `json:"messages"`
}
func (r IndexMessagesResponse) String() string {
return marshal(&r)
}
/*
IndexMessages -
Retrieve messages for a group.
By default, messages are returned in groups of 20, ordered by
created_at descending. This can be raised or lowered by passing
a limit parameter, up to a maximum of 100 messages.
Messages can be scanned by providing a message ID as either the
before_id, since_id, or after_id parameter. If before_id is
provided, then messages immediately preceding the given message
will be returned, in descending order. This can be used to
continually page back through a group's messages.
The after_id parameter will return messages that immediately
follow a given message, this time in ascending order (which
makes it easy to pick off the last result for continued
pagination).
Finally, the since_id parameter also returns messages created
after the given message, but it retrieves the most recent
messages. For example, if more than twenty messages are created
after the since_id message, using this parameter will omit the
messages that immediately follow the given message. This is a
bit counterintuitive, so take care.
If no messages are found (e.g. when filtering with before_id)
we return code 304.
Note that for historical reasons, likes are returned as an
array of user ids in the favorited_by key.
Parameters: See MessageQuery
*/
func (c *Client) IndexMessages(groupID ID, req *IndexMessagesQuery) (IndexMessagesResponse, error) {
url := fmt.Sprintf(c.endpointBase+indexMessagesEndpoint, groupID)
httpReq, err := http.NewRequest("GET", url, nil)
if err != nil {
return IndexMessagesResponse{}, err
}
URL := httpReq.URL
query := URL.Query()
if req != nil {
if req.BeforeID != "" {
query.Add("before_id", req.BeforeID.String())
}
if req.SinceID != "" {
query.Add("since_id", req.SinceID.String())
}
if req.AfterID != "" {
query.Add("after_id", req.AfterID.String())
}
if req.Limit != 0 {
query.Add("limit", strconv.Itoa(req.Limit))
}
}
URL.RawQuery = query.Encode()
var resp IndexMessagesResponse
err = c.do(httpReq, &resp)
if err != nil {
return IndexMessagesResponse{}, err
}
return resp, nil
}
// Create
/*
CreateMessage -
Send a message to a group
If you want to attach an image, you must first process it
through our image service.
Attachments of type emoji rely on data from emoji PowerUps.
Clients use a placeholder character in the message text and
specify a replacement charmap to substitute emoji characters
The character map is an array of arrays containing rune data
([[{pack_id,offset}],...]).
The placeholder should be a high-point/invisible UTF-8 character.
Parameters:
groupID - required, ID(String)
See Message.
text - required, string. Can be ommitted if at least one
attachment is present
attachments - a polymorphic list of attachments (locations,
images, etc). You may have You may have more than
one of any type of attachment, provided clients can
display it.
*/
func (c *Client) CreateMessage(groupID ID, m *Message) (*Message, error) {
URL := fmt.Sprintf(c.endpointBase+createMessagesEndpoint, groupID)
httpReq, err := http.NewRequest("POST", URL, nil)
if err != nil {
return nil, err
}
m.SourceGUID = uuid.New().String()
data := url.Values{}
data.Set("message", m.String())
httpReq.PostForm = data
var resp struct {
*Message `json:"message"`
}
err = c.do(httpReq, &resp)
if err != nil {
return nil, err
}
return resp.Message, nil
}

192
messages_api_test.go Normal file
View File

@ -0,0 +1,192 @@
package groupme
import (
"fmt"
"net/http"
"testing"
"github.com/gorilla/mux"
"github.com/stretchr/testify/suite"
)
type MessagesAPISuite struct{ APISuite }
func (s *MessagesAPISuite) SetupSuite() {
s.handler = messagesTestRouter()
s.setupSuite()
}
func (s *MessagesAPISuite) TestMessagesIndex() {
resp, err := s.client.IndexMessages(
ID("123"),
&IndexMessagesQuery{
BeforeID: "0123456789",
SinceID: "9876543210",
AfterID: "0246813579",
Limit: 20,
},
)
s.Require().NoError(err)
s.Require().NotZero(resp)
for _, message := range resp.Messages {
s.Assert().NotZero(message)
}
}
func (s *MessagesAPISuite) TestMessagesCreate() {
message, err := s.client.CreateMessage(
ID("123"),
&Message{
Text: "Test",
},
)
s.Require().NoError(err)
s.Require().NotNil(message)
s.Assert().NotZero(*message)
}
func TestMessagesAPISuite(t *testing.T) {
suite.Run(t, new(MessagesAPISuite))
}
func messagesTestRouter() *mux.Router {
router := mux.NewRouter()
// Index
router.Path("/groups/{id:[0-9]+}/messages").
Methods("GET").
Name("IndexMessages").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
fmt.Fprint(w, `{
"response": {
"count": 123,
"messages": [
{
"id": "1234567890",
"source_guid": "GUID",
"created_at": 1302623328,
"user_id": "1234567890",
"group_id": "1234567890",
"name": "John",
"avatar_url": "https://i.groupme.com/123456789",
"text": "Hello world ☃☃",
"system": true,
"favorited_by": [
"101",
"66",
"1234567890"
],
"attachments": [
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "location",
"lat": "40.738206",
"lng": "-73.993285",
"name": "GroupMe HQ"
},
{
"type": "split",
"token": "SPLIT_TOKEN"
},
{
"type": "emoji",
"placeholder": "☃",
"charmap": [
[
1,
42
],
[
2,
34
]
]
}
]
}
]
},
"meta": {
"code": 200,
"errors": []
}
}`)
})
// Create
router.Path("/groups/{id:[0-9]+}/messages").
Methods("POST").
Name("CreateMessages").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(201)
fmt.Fprint(w, `{
"response": {
"message": {
"id": "1234567890",
"source_guid": "GUID",
"created_at": 1302623328,
"user_id": "1234567890",
"group_id": "1234567890",
"name": "John",
"avatar_url": "https://i.groupme.com/123456789",
"text": "Hello world ☃☃",
"system": true,
"favorited_by": [
"101",
"66",
"1234567890"
],
"attachments": [
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "image",
"url": "https://i.groupme.com/123456789"
},
{
"type": "location",
"lat": "40.738206",
"lng": "-73.993285",
"name": "GroupMe HQ"
},
{
"type": "split",
"token": "SPLIT_TOKEN"
},
{
"type": "emoji",
"placeholder": "☃",
"charmap": [
[
1,
42
],
[
2,
34
]
]
}
]
}
},
"meta": {
"code": 201,
"errors": []
}
}`)
})
/*// Return test router //*/
return router
}

77
sms_mode_api.go Normal file
View File

@ -0,0 +1,77 @@
package groupme
import (
"fmt"
"net/http"
"net/url"
"strconv"
)
// GroupMe documentation: https://dev.groupme.com/docs/v3#sms_mode
////////// Endpoints //////////
const (
// Used to build other endpoints
smsModeEndpointRoot = usersEndpointRoot + "/sms_mode"
// Actual Endpoints
createSMSModeEndpoint = smsModeEndpointRoot // POST
deleteSMSModeEndpoint = smsModeEndpointRoot + "/delete" // POST
)
////////// API Requests //////////
// Create
/*
CreateSMSMode -
Enables SMS mode for N hours, where N is at most 48. After N
hours have elapsed, user will receive push notfications.
Parameters:
duration - required, integer
registration_id - string; The push notification ID/token
that should be suppressed during SMS mode. If this is
omitted, both SMS and push notifications will be
delivered to the device.
*/
func (c *Client) CreateSMSMode(duration int, registrationID *ID) error {
httpReq, err := http.NewRequest("POST", c.endpointBase+createSMSModeEndpoint, nil)
if err != nil {
return err
}
data := url.Values{}
data.Add("duration", strconv.Itoa(duration))
if registrationID != nil {
data.Add("registration_id", registrationID.String())
}
httpReq.PostForm = data
err = c.do(httpReq, nil)
if err != nil {
return err
}
return nil
}
// Delete
/*
DeleteSMSMode -
Disables SMS mode
*/
func (c *Client) DeleteSMSMode() error {
url := fmt.Sprintf(c.endpointBase + deleteSMSModeEndpoint)
httpReq, err := http.NewRequest("POST", url, nil)
if err != nil {
return err
}
return c.do(httpReq, nil)
}

50
sms_mode_api_test.go Normal file
View File

@ -0,0 +1,50 @@
package groupme
import (
"net/http"
"testing"
"github.com/gorilla/mux"
"github.com/stretchr/testify/suite"
)
type SMSModeAPISuite struct{ APISuite }
func (s *SMSModeAPISuite) SetupSuite() {
s.handler = smsModeTestRouter()
s.setupSuite()
}
func (s *SMSModeAPISuite) TestSMSModeCreate() {
s.Assert().NoError(s.client.CreateSMSMode(10, nil))
}
func (s *SMSModeAPISuite) TestSMSModeDelete() {
s.Assert().NoError(s.client.DeleteSMSMode())
}
func TestSMSModeAPISuite(t *testing.T) {
suite.Run(t, new(SMSModeAPISuite))
}
func smsModeTestRouter() *mux.Router {
router := mux.NewRouter()
// Create
router.Path("/users/sms_mode").
Methods("POST").
Name("CreateSMSMode").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(201)
})
// Delete
router.Path("/users/sms_mode/delete").
Methods("POST").
Name("DeleteSMSMode").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
})
/*// Return test router //*/
return router
}

105
users_api.go Normal file
View File

@ -0,0 +1,105 @@
package groupme
import (
"fmt"
"net/http"
"net/url"
)
// GroupMe documentation: https://dev.groupme.com/docs/v3#users
////////// Endpoints //////////
const (
// Used to build other endpoints
usersEndpointRoot = "/users"
// Actual Endpoints
myUserEndpoint = usersEndpointRoot + "/me" // GET
updateMyUserEndpoint = usersEndpointRoot + "/update" // POST
)
////////// API Requests //////////
// Me
/*
MyUser -
Loads a specific group.
Parameters:
groupID - required, ID(string)
*/
func (c *Client) MyUser() (*User, error) {
URL := fmt.Sprintf(c.endpointBase + myUserEndpoint)
httpReq, err := http.NewRequest("GET", URL, nil)
if err != nil {
return nil, err
}
var resp User
err = c.do(httpReq, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}
// Update
type UserSettings struct {
// URL to valid JPG/PNG/GIF image. URL will be converted into
// an image service link (https://i.groupme.com/....)
AvatarURL string `json:"avatar_url"`
// Name must be of the form FirstName LastName
Name string `json:"name"`
// Email address. Must be in name@domain.com form
Email string `json:"email"`
ZipCode string `json:"zip_code"`
}
/*
UpdateMyUser -
Update attributes about your own account
Parameters: See UserSettings
*/
func (c *Client) UpdateMyUser(us UserSettings) (*User, error) {
URL := fmt.Sprintf(c.endpointBase + updateMyUserEndpoint)
httpReq, err := http.NewRequest("POST", URL, nil)
if err != nil {
return nil, err
}
data := url.Values{}
if us.AvatarURL != "" {
data.Add("avatar_url", us.AvatarURL)
}
if us.Name != "" {
data.Add("name", us.Name)
}
if us.Email != "" {
data.Add("email", us.Email)
}
if us.ZipCode != "" {
data.Add("zip_code", us.ZipCode)
}
httpReq.PostForm = data
var resp User
err = c.do(httpReq, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}

87
users_api_test.go Normal file
View File

@ -0,0 +1,87 @@
package groupme
import (
"fmt"
"net/http"
"testing"
"github.com/gorilla/mux"
"github.com/stretchr/testify/suite"
)
type UsersAPISuite struct{ APISuite }
func (s *UsersAPISuite) SetupSuite() {
s.handler = usersTestRouter()
s.setupSuite()
}
func (s *UsersAPISuite) TestUsersMe() {
user, err := s.client.MyUser()
s.Require().NoError(err)
s.Assert().NotZero(user)
}
func (s *UsersAPISuite) TestUsersUpdate() {
user, err := s.client.UpdateMyUser(UserSettings{})
s.Require().NoError(err)
s.Assert().NotZero(user)
}
func TestUsersAPISuite(t *testing.T) {
suite.Run(t, new(UsersAPISuite))
}
func usersTestRouter() *mux.Router {
router := mux.NewRouter()
// Me
router.Path("/users/me").
Methods("GET").
Name("MyUser").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
fmt.Fprint(w, `{
"response": {
"id": "1234567890",
"phone_number": "+1 2123001234",
"image_url": "https://i.groupme.com/123456789",
"name": "Ronald Swanson",
"created_at": 1302623328,
"updated_at": 1302623328,
"email": "me@example.com",
"sms": false
},
"meta": {
"code": 200,
"errors": []
}
}`)
})
// Update
router.Path("/users/update").
Methods("POST").
Name("UpdateMyUser").
HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
fmt.Fprint(w, `{
"response": {
"id": "1234567890",
"phone_number": "+1 2123001234",
"image_url": "https://i.groupme.com/123456789",
"name": "Ronald Swanson",
"created_at": 1302623328,
"updated_at": 1302623328,
"email": "me@example.com",
"sms": false
},
"meta": {
"code": 200,
"errors": []
}
}`)
})
/*// Return test router //*/
return router
}