├── .github ├── dependabot.yaml └── workflows │ └── test.yaml ├── .gitignore ├── CHANGES.md ├── LICENSE ├── Makefile ├── README.md ├── examples ├── README.md ├── app-auth.go ├── direct_messages.go ├── go.mod ├── go.sum ├── streaming.go └── user-auth.go ├── go.mod ├── go.sum └── twitter ├── accounts.go ├── accounts_test.go ├── backoffs.go ├── backoffs_test.go ├── block.go ├── block_test.go ├── demux.go ├── demux_test.go ├── direct_messages.go ├── direct_messages_test.go ├── doc.go ├── entities.go ├── entities_test.go ├── errors.go ├── errors_test.go ├── favorites.go ├── favorites_test.go ├── followers.go ├── followers_test.go ├── friends.go ├── friends_test.go ├── friendships.go ├── friendships_test.go ├── lists.go ├── lists_test.go ├── premium_search.go ├── premium_search_test.go ├── rate_limits.go ├── rate_limits_test.go ├── search.go ├── search_test.go ├── statuses.go ├── statuses_test.go ├── stream_messages.go ├── stream_utils.go ├── stream_utils_test.go ├── streams.go ├── streams_test.go ├── timelines.go ├── timelines_test.go ├── trends.go ├── trends_test.go ├── twitter.go ├── twitter_test.go ├── users.go └── users_test.go /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | pull-request-branch-name: 8 | separator: "-" 9 | open-pull-requests-limit: 3 10 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | jobs: 8 | build: 9 | name: go 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | go: ['1.17', '1.18', '1.19'] 15 | steps: 16 | - name: setup 17 | uses: actions/setup-go@v3 18 | with: 19 | go-version: ${{matrix.go}} 20 | 21 | - name: checkout 22 | uses: actions/checkout@v3 23 | 24 | - name: test 25 | run: make 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /bin -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # go-twitter Changelog 2 | 3 | Notable changes over time. Note, `go-twitter` does not follow a semver release cycle since it may change whenever the Twitter API changes (external). 4 | 5 | ## 07/2019 6 | 7 | * Add Go module support ([#143](https://github.com/dghubble/go-twitter/pull/143)) 8 | 9 | ## 11/2018 10 | 11 | * Add `DirectMessageService` support for the new Twitter Direct Message Events [API](https://developer.twitter.com/en/docs/direct-messages/api-features) ([#125](https://github.com/dghubble/go-twitter/pull/125)) 12 | * Add `EventsNew` method for sending a Direct Message event 13 | * Add `EventsShow` method for getting a single Direct Message event 14 | * Add `EventsList` method for listing recent Direct Message events 15 | * Add`EventsDestroy` method for destroying a Direct Message event 16 | 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Dalton Hubble 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: test vet fmt 3 | 4 | .PHONY: test 5 | test: 6 | @go test ./twitter -cover 7 | 8 | .PHONY: vet 9 | vet: 10 | @go vet -all ./twitter 11 | 12 | .PHONY: fmt 13 | fmt: 14 | @test -z $$(go fmt ./...) 15 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-twitter [![GoDoc](https://pkg.go.dev/badge/github.com/dghubble/go-twitter.svg)](https://pkg.go.dev/github.com/dghubble/go-twitter) [![Workflow](https://github.com/dghubble/go-twitter/actions/workflows/test.yaml/badge.svg)](https://github.com/dghubble/go-twitter/actions/workflows/test.yaml?query=branch%3Amain) [![Sponsors](https://img.shields.io/github/sponsors/dghubble?logo=github)](https://github.com/sponsors/dghubble) [![Twitter](https://img.shields.io/badge/twitter-follow-1da1f2?logo=twitter)](https://twitter.com/dghubble) 2 | 3 | **DEPRECATED** As of Nov 2022, the go-twitter API library is no longer being developed. If you fork this repo, please remove the logo since it is not covered by the license. 4 | 5 | 6 | 7 | go-twitter is a Go client library for the [Twitter API](https://dev.twitter.com/rest/public). Check the [usage](#usage) section or try the [examples](/examples) to see how to access the Twitter API. 8 | 9 | ### Features 10 | 11 | * Twitter REST API: 12 | * Accounts 13 | * Blocks 14 | * DirectMessageEvents 15 | * Favorites 16 | * Friends 17 | * Friendships 18 | * Followers 19 | * Lists 20 | * PremiumSearch 21 | * RateLimits 22 | * Search 23 | * Statuses 24 | * Timelines 25 | * Users 26 | * Twitter Streaming API 27 | * Public Streams 28 | * User Streams 29 | * Site Streams 30 | * Firehose Streams 31 | 32 | ## Install 33 | 34 | go get github.com/dghubble/go-twitter/twitter 35 | 36 | ## Documentation 37 | 38 | Read [GoDoc](https://godoc.org/github.com/dghubble/go-twitter/twitter) 39 | 40 | ## Usage 41 | 42 | ### REST API 43 | 44 | The `twitter` package provides a `Client` for accessing the Twitter API. Here are some example requests. 45 | 46 | ```go 47 | config := oauth1.NewConfig("consumerKey", "consumerSecret") 48 | token := oauth1.NewToken("accessToken", "accessSecret") 49 | httpClient := config.Client(oauth1.NoContext, token) 50 | 51 | // Twitter client 52 | client := twitter.NewClient(httpClient) 53 | 54 | // Home Timeline 55 | tweets, resp, err := client.Timelines.HomeTimeline(&twitter.HomeTimelineParams{ 56 | Count: 20, 57 | }) 58 | 59 | // Send a Tweet 60 | tweet, resp, err := client.Statuses.Update("just setting up my twttr", nil) 61 | 62 | // Status Show 63 | tweet, resp, err := client.Statuses.Show(585613041028431872, nil) 64 | 65 | // Search Tweets 66 | search, resp, err := client.Search.Tweets(&twitter.SearchTweetParams{ 67 | Query: "gopher", 68 | }) 69 | 70 | // User Show 71 | user, resp, err := client.Users.Show(&twitter.UserShowParams{ 72 | ScreenName: "dghubble", 73 | }) 74 | 75 | // Followers 76 | followers, resp, err := client.Followers.List(&twitter.FollowerListParams{}) 77 | ``` 78 | 79 | Authentication is handled by the `http.Client` passed to `NewClient` to handle user auth (OAuth1) or application auth (OAuth2). See the [Authentication](#authentication) section. 80 | 81 | Required parameters are passed as positional arguments. Optional parameters are passed typed params structs (or nil). 82 | 83 | ## Streaming API 84 | 85 | The Twitter Public, User, Site, and Firehose Streaming APIs can be accessed through the `Client` `StreamService` which provides methods `Filter`, `Sample`, `User`, `Site`, and `Firehose`. 86 | 87 | Create a `Client` with an authenticated `http.Client`. All stream endpoints require a user auth context so choose an OAuth1 `http.Client`. 88 | 89 | client := twitter.NewClient(httpClient) 90 | 91 | Next, request a managed `Stream` be started. 92 | 93 | #### Filter 94 | 95 | Filter Streams return Tweets that match one or more filtering predicates such as `Track`, `Follow`, and `Locations`. 96 | 97 | ```go 98 | params := &twitter.StreamFilterParams{ 99 | Track: []string{"kitten"}, 100 | StallWarnings: twitter.Bool(true), 101 | } 102 | stream, err := client.Streams.Filter(params) 103 | ``` 104 | 105 | #### User 106 | 107 | User Streams provide messages specific to the authenticate User and possibly those they follow. 108 | 109 | ```go 110 | params := &twitter.StreamUserParams{ 111 | With: "followings", 112 | StallWarnings: twitter.Bool(true), 113 | } 114 | stream, err := client.Streams.User(params) 115 | ``` 116 | 117 | *Note* To see Direct Message events, your consumer application must ask Users for read/write/DM access to their account. 118 | 119 | #### Sample 120 | 121 | Sample Streams return a small sample of public Tweets. 122 | 123 | ```go 124 | params := &twitter.StreamSampleParams{ 125 | StallWarnings: twitter.Bool(true), 126 | } 127 | stream, err := client.Streams.Sample(params) 128 | ``` 129 | 130 | #### Site, Firehose 131 | 132 | Site and Firehose Streams require your application to have special permissions, but their API works the same way. 133 | 134 | ### Receiving Messages 135 | 136 | Each `Stream` maintains the connection to the Twitter Streaming API endpoint, receives messages, and sends them on the `Stream.Messages` channel. 137 | 138 | Go channels support range iterations which allow you to read the messages which are of type `interface{}`. 139 | 140 | ```go 141 | for message := range stream.Messages { 142 | fmt.Println(message) 143 | } 144 | ``` 145 | 146 | If you run this in your main goroutine, it will receive messages forever unless the stream stops. To continue execution, receive messages using a separate goroutine. 147 | 148 | ### Demux 149 | 150 | Receiving messages of type `interface{}` isn't very nice, it means you'll have to type switch and probably filter out message types you don't care about. 151 | 152 | For this, try a `Demux`, like `SwitchDemux`, which receives messages and type switches them to call functions with typed messages. 153 | 154 | For example, say you're only interested in Tweets and Direct Messages. 155 | 156 | ```go 157 | demux := twitter.NewSwitchDemux() 158 | demux.Tweet = func(tweet *twitter.Tweet) { 159 | fmt.Println(tweet.Text) 160 | } 161 | demux.DM = func(dm *twitter.DirectMessage) { 162 | fmt.Println(dm.SenderID) 163 | } 164 | ``` 165 | 166 | Pass the `Demux` each message or give it the entire `Stream.Message` channel. 167 | 168 | ```go 169 | for message := range stream.Messages { 170 | demux.Handle(message) 171 | } 172 | // or pass the channel 173 | demux.HandleChan(stream.Messages) 174 | ``` 175 | 176 | ### Stopping 177 | 178 | The `Stream` will stop itself if the stream disconnects and retrying produces unrecoverable errors. When this occurs, `Stream` will close the `stream.Messages` channel, so execution will break out of any message *for range* loops. 179 | 180 | When you are finished receiving from a `Stream`, call `Stop()` which closes the connection, channels, and stops the goroutine **before** returning. This ensures resources are properly cleaned up. 181 | 182 | ### Pitfalls 183 | 184 | **Bad**: In this example, `Stop()` is unlikely to be reached. Control stays in the message loop unless the `Stream` becomes disconnected and cannot retry. 185 | 186 | ```go 187 | // program does not terminate :( 188 | stream, _ := client.Streams.Sample(params) 189 | for message := range stream.Messages { 190 | demux.Handle(message) 191 | } 192 | stream.Stop() 193 | ``` 194 | 195 | **Bad**: Here, messages are received on a non-main goroutine, but then `Stop()` is called immediately. The `Stream` is stopped and the program exits. 196 | 197 | ```go 198 | // got no messages :( 199 | stream, _ := client.Streams.Sample(params) 200 | go demux.HandleChan(stream.Messages) 201 | stream.Stop() 202 | ``` 203 | 204 | **Good**: For main package scripts, one option is to receive messages in a goroutine and wait for CTRL-C to be pressed, then explicitly stop the `Stream`. 205 | 206 | ```go 207 | stream, err := client.Streams.Sample(params) 208 | go demux.HandleChan(stream.Messages) 209 | 210 | // Wait for SIGINT and SIGTERM (HIT CTRL-C) 211 | ch := make(chan os.Signal) 212 | signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) 213 | log.Println(<-ch) 214 | 215 | stream.Stop() 216 | ``` 217 | 218 | ## Authentication 219 | 220 | The API client accepts an any `http.Client` capable of making user auth (OAuth1) or application auth (OAuth2) authorized requests. See the [dghubble/oauth1](https://github.com/dghubble/oauth1) and [golang/oauth2](https://github.com/golang/oauth2/) packages which can provide such agnostic clients. 221 | 222 | Passing an `http.Client` directly grants you control over the underlying transport, avoids dependencies on particular OAuth1 or OAuth2 packages, and keeps client APIs separate from authentication protocols. 223 | 224 | See the [google/go-github](https://github.com/google/go-github) client which takes the same approach. 225 | 226 | For example, make requests as a consumer application on behalf of a user who has granted access, with OAuth1. 227 | 228 | ```go 229 | // OAuth1 230 | import ( 231 | "github.com/dghubble/go-twitter/twitter" 232 | "github.com/dghubble/oauth1" 233 | ) 234 | 235 | config := oauth1.NewConfig("consumerKey", "consumerSecret") 236 | token := oauth1.NewToken("accessToken", "accessSecret") 237 | // http.Client will automatically authorize Requests 238 | httpClient := config.Client(oauth1.NoContext, token) 239 | 240 | // Twitter client 241 | client := twitter.NewClient(httpClient) 242 | ``` 243 | 244 | If no user auth context is needed, make requests as your application with application auth. 245 | 246 | ```go 247 | // OAuth2 248 | import ( 249 | "github.com/dghubble/go-twitter/twitter" 250 | "golang.org/x/oauth2" 251 | "golang.org/x/oauth2/clientcredentials" 252 | ) 253 | 254 | // oauth2 configures a client that uses app credentials to keep a fresh token 255 | config := &clientcredentials.Config{ 256 | ClientID: "consumerKey", 257 | ClientSecret: "consumerSecret", 258 | TokenURL: "https://api.twitter.com/oauth2/token", 259 | } 260 | // http.Client will automatically authorize Requests 261 | httpClient := config.Client(oauth2.NoContext) 262 | 263 | // Twitter client 264 | client := twitter.NewClient(httpClient) 265 | ``` 266 | 267 | To implement Login with Twitter for web or mobile, see the gologin [package](https://github.com/dghubble/gologin) and [examples](https://github.com/dghubble/gologin/tree/master/examples/twitter). 268 | 269 | ## Roadmap 270 | 271 | * Support gzipped streams 272 | * Auto-stop streams in the event of long stalls 273 | 274 | ## Contributing 275 | 276 | See the [Contributing Guide](https://gist.github.com/dghubble/be682c123727f70bcfe7). 277 | 278 | ## License 279 | 280 | [MIT License](LICENSE) 281 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | 2 | # Examples 3 | 4 | Get the dependencies and examples 5 | 6 | cd examples 7 | go get . 8 | 9 | ## User Auth (OAuth1) 10 | 11 | A user access token (OAuth1) grants a consumer application access to a user's Twitter resources. 12 | 13 | Setup an OAuth1 `http.Client` with the consumer key and secret and oauth token and secret. 14 | ``` 15 | export TWITTER_CONSUMER_KEY=xxx 16 | export TWITTER_CONSUMER_SECRET=xxx 17 | export TWITTER_ACCESS_TOKEN=xxx 18 | export TWITTER_ACCESS_SECRET=xxx 19 | ``` 20 | 21 | To make requests as an application, on behalf of a user, create a `twitter` `Client` to get the home timeline, mention timeline, and more (example will **not** post Tweets). 22 | 23 | ``` 24 | go run user-auth.go 25 | ``` 26 | 27 | ## App Auth (OAuth2) 28 | 29 | An application access token (OAuth2) allows an application to make Twitter API requests for public content, with rate limits counting against the app itself. App auth requests can be made to API endpoints which do not require a user context. 30 | 31 | Setup an OAuth2 `http.Client` with the Twitter consumer key and secret. 32 | 33 | ``` 34 | export TWITTER_CONSUMER_KEY=xxx 35 | export TWITTER_CONSUMER_SECRET=xxx 36 | ``` 37 | 38 | To make requests as an application, create a `twitter` `Client` and get public Tweets or timelines or other public content. 39 | 40 | ``` 41 | go run app-auth.go 42 | ``` 43 | 44 | ## Streaming API 45 | 46 | A user access token (OAuth1) is required for Streaming API requests. See above. 47 | 48 | ``` 49 | go run streaming.go 50 | ``` 51 | 52 | Hit CTRL-C to stop streaming. Uncomment different examples in code to try different streams. 53 | -------------------------------------------------------------------------------- /examples/app-auth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/coreos/pkg/flagutil" 9 | "github.com/dghubble/go-twitter/twitter" 10 | "golang.org/x/oauth2" 11 | "golang.org/x/oauth2/clientcredentials" 12 | ) 13 | 14 | func main() { 15 | flags := struct { 16 | consumerKey string 17 | consumerSecret string 18 | }{} 19 | 20 | flag.StringVar(&flags.consumerKey, "consumer-key", "", "Twitter Consumer Key") 21 | flag.StringVar(&flags.consumerSecret, "consumer-secret", "", "Twitter Consumer Secret") 22 | flag.Parse() 23 | flagutil.SetFlagsFromEnv(flag.CommandLine, "TWITTER") 24 | 25 | if flags.consumerKey == "" || flags.consumerSecret == "" { 26 | log.Fatal("Application Access Token required") 27 | } 28 | 29 | // oauth2 configures a client that uses app credentials to keep a fresh token 30 | config := &clientcredentials.Config{ 31 | ClientID: flags.consumerKey, 32 | ClientSecret: flags.consumerSecret, 33 | TokenURL: "https://api.twitter.com/oauth2/token", 34 | } 35 | // http.Client will automatically authorize Requests 36 | httpClient := config.Client(oauth2.NoContext) 37 | 38 | // Twitter client 39 | client := twitter.NewClient(httpClient) 40 | 41 | // user show 42 | userShowParams := &twitter.UserShowParams{ScreenName: "golang"} 43 | user, _, _ := client.Users.Show(userShowParams) 44 | fmt.Printf("USERS SHOW:\n%+v\n", user) 45 | 46 | // users lookup 47 | userLookupParams := &twitter.UserLookupParams{ScreenName: []string{"golang", "gophercon"}} 48 | users, _, _ := client.Users.Lookup(userLookupParams) 49 | fmt.Printf("USERS LOOKUP:\n%+v\n", users) 50 | 51 | // status show 52 | statusShowParams := &twitter.StatusShowParams{} 53 | tweet, _, _ := client.Statuses.Show(584077528026849280, statusShowParams) 54 | fmt.Printf("STATUSES SHOW:\n%+v\n", tweet) 55 | 56 | // statuses lookup 57 | statusLookupParams := &twitter.StatusLookupParams{ID: []int64{20}, TweetMode: "extended"} 58 | tweets, _, _ := client.Statuses.Lookup([]int64{573893817000140800}, statusLookupParams) 59 | fmt.Printf("STATUSES LOOKUP:\n%+v\n", tweets) 60 | 61 | // oEmbed status 62 | statusOembedParams := &twitter.StatusOEmbedParams{ID: 691076766878691329, MaxWidth: 500} 63 | oembed, _, _ := client.Statuses.OEmbed(statusOembedParams) 64 | fmt.Printf("OEMBED TWEET:\n%+v\n", oembed) 65 | 66 | // user timeline 67 | userTimelineParams := &twitter.UserTimelineParams{ScreenName: "golang", Count: 2} 68 | tweets, _, _ = client.Timelines.UserTimeline(userTimelineParams) 69 | fmt.Printf("USER TIMELINE:\n%+v\n", tweets) 70 | 71 | // search tweets 72 | searchTweetParams := &twitter.SearchTweetParams{ 73 | Query: "happy birthday", 74 | TweetMode: "extended", 75 | Count: 3, 76 | } 77 | search, _, _ := client.Search.Tweets(searchTweetParams) 78 | fmt.Printf("SEARCH TWEETS:\n%+v\n", search) 79 | fmt.Printf("SEARCH METADATA:\n%+v\n", search.Metadata) 80 | } 81 | -------------------------------------------------------------------------------- /examples/direct_messages.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | "github.com/coreos/pkg/flagutil" 10 | "github.com/dghubble/go-twitter/twitter" 11 | "github.com/dghubble/oauth1" 12 | ) 13 | 14 | func main() { 15 | flags := flag.NewFlagSet("user-auth", flag.ExitOnError) 16 | consumerKey := flags.String("consumer-key", "", "Twitter Consumer Key") 17 | consumerSecret := flags.String("consumer-secret", "", "Twitter Consumer Secret") 18 | accessToken := flags.String("access-token", "", "Twitter Access Token") 19 | accessSecret := flags.String("access-secret", "", "Twitter Access Secret") 20 | flags.Parse(os.Args[1:]) 21 | flagutil.SetFlagsFromEnv(flags, "TWITTER") 22 | 23 | if *consumerKey == "" || *consumerSecret == "" || *accessToken == "" || *accessSecret == "" { 24 | log.Fatal("Consumer key/secret and Access token/secret required") 25 | } 26 | 27 | config := oauth1.NewConfig(*consumerKey, *consumerSecret) 28 | token := oauth1.NewToken(*accessToken, *accessSecret) 29 | // OAuth1 http.Client will automatically authorize Requests 30 | httpClient := config.Client(oauth1.NoContext, token) 31 | 32 | // Twitter client 33 | client := twitter.NewClient(httpClient) 34 | 35 | // List most recent 10 Direct Messages 36 | messages, _, err := client.DirectMessages.EventsList( 37 | &twitter.DirectMessageEventsListParams{Count: 10}, 38 | ) 39 | fmt.Println("User's DIRECT MESSAGES:") 40 | if err != nil { 41 | log.Fatal(err) 42 | } 43 | for _, event := range messages.Events { 44 | fmt.Printf("%+v\n", event) 45 | fmt.Printf(" %+v\n", event.Message) 46 | fmt.Printf(" %+v\n", event.Message.Data) 47 | } 48 | 49 | // Show Direct Message event 50 | event, _, err := client.DirectMessages.EventsShow("1066903366071017476", nil) 51 | fmt.Printf("DM Events Show:\n%+v, %v\n", event.Message.Data, err) 52 | 53 | // Create Direct Message event 54 | /* 55 | event, _, err = client.DirectMessages.EventsNew(&twitter.DirectMessageEventsNewParams{ 56 | Event: &twitter.DirectMessageEvent{ 57 | Type: "message_create", 58 | Message: &twitter.DirectMessageEventMessage{ 59 | Target: &twitter.DirectMessageTarget{ 60 | RecipientID: "2856535627", 61 | }, 62 | Data: &twitter.DirectMessageData{ 63 | Text: "testing", 64 | }, 65 | }, 66 | }, 67 | }) 68 | fmt.Printf("DM Event New:\n%+v, %v\n", event, err) 69 | */ 70 | 71 | // Destroy Direct Message event 72 | //_, err = client.DirectMessages.EventsDestroy("1066904217049133060") 73 | //fmt.Printf("DM Events Delete:\n err: %v\n", err) 74 | } 75 | -------------------------------------------------------------------------------- /examples/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dghubble/go-twitter/examples 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f 7 | github.com/dghubble/go-twitter v0.0.0-20220716034336-7f63262ef83a 8 | github.com/dghubble/oauth1 v0.6.0 9 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 10 | ) 11 | 12 | require ( 13 | github.com/cenkalti/backoff/v4 v4.1.3 // indirect 14 | github.com/dghubble/sling v1.4.0 // indirect 15 | github.com/golang/protobuf v1.2.0 // indirect 16 | github.com/google/go-querystring v1.1.0 // indirect 17 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e // indirect 18 | google.golang.org/appengine v1.4.0 // indirect 19 | ) 20 | 21 | replace github.com/dghubble/go-twitter/twitter => ../ 22 | -------------------------------------------------------------------------------- /examples/go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4= 3 | github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= 4 | github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f h1:lBNOc5arjvs8E5mO2tbpBpLoyyu8B6e44T7hJy6potg= 5 | github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= 6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/dghubble/go-twitter v0.0.0-20220716034336-7f63262ef83a h1:QDqWWmsz3yf+li4eq4ydnMQUBGiwagNlgRkv0MuALxg= 10 | github.com/dghubble/go-twitter v0.0.0-20220716034336-7f63262ef83a/go.mod h1:q7VYuSasPO79IE/QBNAMYVNlzZNy4Zr7vay6is50u5I= 11 | github.com/dghubble/oauth1 v0.6.0 h1:m1yC01Ohc/eF38jwZ8JUjL1a+XHHXtGQgK+MxQbmSx0= 12 | github.com/dghubble/oauth1 v0.6.0/go.mod h1:8pFdfPkv/jr8mkChVbNVuJ0suiHe278BtWI4Tk1ujxk= 13 | github.com/dghubble/sling v1.4.0 h1:/n8MRosVTthvMbwlNZgLx579OGVjUOy3GNEv5BIqAWY= 14 | github.com/dghubble/sling v1.4.0/go.mod h1:0r40aNsU9EdDUVBNhfCstAtFgutjgJGYbO1oNzkMoM8= 15 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= 16 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 17 | github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= 18 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 19 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 20 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 21 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 22 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 23 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 24 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 25 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 26 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 27 | github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= 28 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 29 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 30 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e h1:bRhVy7zSSasaqNksaRZiA5EEI+Ei4I1nO5Jh72wfHlg= 31 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 32 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= 33 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 34 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw= 35 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 36 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 37 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 38 | google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= 39 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 40 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 41 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 42 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 43 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 44 | -------------------------------------------------------------------------------- /examples/streaming.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | 11 | "github.com/coreos/pkg/flagutil" 12 | "github.com/dghubble/go-twitter/twitter" 13 | "github.com/dghubble/oauth1" 14 | ) 15 | 16 | func main() { 17 | flags := flag.NewFlagSet("user-auth", flag.ExitOnError) 18 | consumerKey := flags.String("consumer-key", "", "Twitter Consumer Key") 19 | consumerSecret := flags.String("consumer-secret", "", "Twitter Consumer Secret") 20 | accessToken := flags.String("access-token", "", "Twitter Access Token") 21 | accessSecret := flags.String("access-secret", "", "Twitter Access Secret") 22 | flags.Parse(os.Args[1:]) 23 | flagutil.SetFlagsFromEnv(flags, "TWITTER") 24 | 25 | if *consumerKey == "" || *consumerSecret == "" || *accessToken == "" || *accessSecret == "" { 26 | log.Fatal("Consumer key/secret and Access token/secret required") 27 | } 28 | 29 | config := oauth1.NewConfig(*consumerKey, *consumerSecret) 30 | token := oauth1.NewToken(*accessToken, *accessSecret) 31 | // OAuth1 http.Client will automatically authorize Requests 32 | httpClient := config.Client(oauth1.NoContext, token) 33 | 34 | // Twitter Client 35 | client := twitter.NewClient(httpClient) 36 | 37 | // Convenience Demux demultiplexed stream messages 38 | demux := twitter.NewSwitchDemux() 39 | demux.Tweet = func(tweet *twitter.Tweet) { 40 | fmt.Println(tweet.Text) 41 | } 42 | demux.DM = func(dm *twitter.DirectMessage) { 43 | fmt.Println(dm.SenderID) 44 | } 45 | demux.Event = func(event *twitter.Event) { 46 | fmt.Printf("%#v\n", event) 47 | } 48 | 49 | fmt.Println("Starting Stream...") 50 | 51 | // FILTER 52 | filterParams := &twitter.StreamFilterParams{ 53 | Track: []string{"cat"}, 54 | StallWarnings: twitter.Bool(true), 55 | } 56 | stream, err := client.Streams.Filter(filterParams) 57 | if err != nil { 58 | log.Fatal(err) 59 | } 60 | 61 | // USER (quick test: auth'd user likes a tweet -> event) 62 | // userParams := &twitter.StreamUserParams{ 63 | // StallWarnings: twitter.Bool(true), 64 | // With: "followings", 65 | // Language: []string{"en"}, 66 | // } 67 | // stream, err := client.Streams.User(userParams) 68 | // if err != nil { 69 | // log.Fatal(err) 70 | // } 71 | 72 | // SAMPLE 73 | // sampleParams := &twitter.StreamSampleParams{ 74 | // StallWarnings: twitter.Bool(true), 75 | // } 76 | // stream, err := client.Streams.Sample(sampleParams) 77 | // if err != nil { 78 | // log.Fatal(err) 79 | // } 80 | 81 | // Receive messages until stopped or stream quits 82 | go demux.HandleChan(stream.Messages) 83 | 84 | // Wait for SIGINT and SIGTERM (HIT CTRL-C) 85 | ch := make(chan os.Signal) 86 | signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) 87 | log.Println(<-ch) 88 | 89 | fmt.Println("Stopping Stream...") 90 | stream.Stop() 91 | } 92 | -------------------------------------------------------------------------------- /examples/user-auth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | "github.com/coreos/pkg/flagutil" 10 | "github.com/dghubble/go-twitter/twitter" 11 | "github.com/dghubble/oauth1" 12 | ) 13 | 14 | func main() { 15 | flags := flag.NewFlagSet("user-auth", flag.ExitOnError) 16 | consumerKey := flags.String("consumer-key", "", "Twitter Consumer Key") 17 | consumerSecret := flags.String("consumer-secret", "", "Twitter Consumer Secret") 18 | accessToken := flags.String("access-token", "", "Twitter Access Token") 19 | accessSecret := flags.String("access-secret", "", "Twitter Access Secret") 20 | flags.Parse(os.Args[1:]) 21 | flagutil.SetFlagsFromEnv(flags, "TWITTER") 22 | 23 | if *consumerKey == "" || *consumerSecret == "" || *accessToken == "" || *accessSecret == "" { 24 | log.Fatal("Consumer key/secret and Access token/secret required") 25 | } 26 | 27 | config := oauth1.NewConfig(*consumerKey, *consumerSecret) 28 | token := oauth1.NewToken(*accessToken, *accessSecret) 29 | // OAuth1 http.Client will automatically authorize Requests 30 | httpClient := config.Client(oauth1.NoContext, token) 31 | 32 | // Twitter client 33 | client := twitter.NewClient(httpClient) 34 | 35 | // Verify Credentials 36 | verifyParams := &twitter.AccountVerifyParams{ 37 | SkipStatus: twitter.Bool(true), 38 | IncludeEmail: twitter.Bool(true), 39 | } 40 | user, _, _ := client.Accounts.VerifyCredentials(verifyParams) 41 | fmt.Printf("User's ACCOUNT:\n%+v\n", user) 42 | 43 | // Home Timeline 44 | homeTimelineParams := &twitter.HomeTimelineParams{ 45 | Count: 2, 46 | TweetMode: "extended", 47 | } 48 | tweets, _, _ := client.Timelines.HomeTimeline(homeTimelineParams) 49 | fmt.Printf("User's HOME TIMELINE:\n%+v\n", tweets) 50 | 51 | // Mention Timeline 52 | mentionTimelineParams := &twitter.MentionTimelineParams{ 53 | Count: 2, 54 | TweetMode: "extended", 55 | } 56 | tweets, _, _ = client.Timelines.MentionTimeline(mentionTimelineParams) 57 | fmt.Printf("User's MENTION TIMELINE:\n%+v\n", tweets) 58 | 59 | // Retweets of Me Timeline 60 | retweetTimelineParams := &twitter.RetweetsOfMeTimelineParams{ 61 | Count: 2, 62 | TweetMode: "extended", 63 | } 64 | tweets, _, _ = client.Timelines.RetweetsOfMeTimeline(retweetTimelineParams) 65 | fmt.Printf("User's 'RETWEETS OF ME' TIMELINE:\n%+v\n", tweets) 66 | 67 | // Update (POST!) Tweet (uncomment to run) 68 | // tweet, _, _ := client.Statuses.Update("just setting up my twttr", nil) 69 | // fmt.Printf("Posted Tweet\n%v\n", tweet) 70 | } 71 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dghubble/go-twitter 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/cenkalti/backoff/v4 v4.1.3 7 | github.com/dghubble/sling v1.4.0 8 | github.com/stretchr/testify v1.8.1 9 | ) 10 | 11 | require ( 12 | github.com/davecgh/go-spew v1.1.1 // indirect 13 | github.com/google/go-querystring v1.1.0 // indirect 14 | github.com/pmezard/go-difflib v1.0.0 // indirect 15 | gopkg.in/yaml.v3 v3.0.1 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4= 2 | github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/dghubble/sling v1.4.0 h1:/n8MRosVTthvMbwlNZgLx579OGVjUOy3GNEv5BIqAWY= 7 | github.com/dghubble/sling v1.4.0/go.mod h1:0r40aNsU9EdDUVBNhfCstAtFgutjgJGYbO1oNzkMoM8= 8 | github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= 9 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 10 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 11 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 12 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 13 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 15 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 16 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 17 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 18 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 19 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 20 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 21 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 22 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 23 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 24 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 25 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 26 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 27 | -------------------------------------------------------------------------------- /twitter/accounts.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/dghubble/sling" 7 | ) 8 | 9 | // AccountService provides a method for account credential verification. 10 | type AccountService struct { 11 | sling *sling.Sling 12 | } 13 | 14 | // newAccountService returns a new AccountService. 15 | func newAccountService(sling *sling.Sling) *AccountService { 16 | return &AccountService{ 17 | sling: sling.Path("account/"), 18 | } 19 | } 20 | 21 | // AccountVerifyParams are the params for AccountService.VerifyCredentials. 22 | type AccountVerifyParams struct { 23 | IncludeEntities *bool `url:"include_entities,omitempty"` 24 | SkipStatus *bool `url:"skip_status,omitempty"` 25 | IncludeEmail *bool `url:"include_email,omitempty"` 26 | } 27 | 28 | // VerifyCredentials returns the authorized user if credentials are valid and 29 | // returns an error otherwise. 30 | // Requires a user auth context. 31 | // https://dev.twitter.com/rest/reference/get/account/verify_credentials 32 | func (s *AccountService) VerifyCredentials(params *AccountVerifyParams) (*User, *http.Response, error) { 33 | user := new(User) 34 | apiError := new(APIError) 35 | resp, err := s.sling.New().Get("verify_credentials.json").QueryStruct(params).Receive(user, apiError) 36 | return user, resp, relevantError(err, *apiError) 37 | } 38 | 39 | // AccountUpdateProfileParams are the params for AccountService.UpdateProfile. 40 | type AccountUpdateProfileParams struct { 41 | Name string `url:"name,omitempty"` 42 | URL string `url:"url,omitempty"` 43 | Location string `url:"location,omitempty"` 44 | Description string `url:"description,omitempty"` 45 | IncludeEntities *bool `url:"include_entities,omitempty"` 46 | SkipStatus *bool `url:"skip_status,omitempty"` 47 | } 48 | 49 | // UpdateProfile updates the account profile with specified fields and returns 50 | // the User. 51 | // Requires a user auth context. 52 | // https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/manage-account-settings/api-reference/post-account-update_profile 53 | func (s *AccountService) UpdateProfile(params *AccountUpdateProfileParams) (*User, *http.Response, error) { 54 | user := new(User) 55 | apiError := new(APIError) 56 | resp, err := s.sling.New().Post("update_profile.json").QueryStruct(params).Receive(user, apiError) 57 | return user, resp, relevantError(err, *apiError) 58 | } 59 | -------------------------------------------------------------------------------- /twitter/accounts_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestAccountService_VerifyCredentials(t *testing.T) { 12 | httpClient, mux, server := testServer() 13 | defer server.Close() 14 | 15 | mux.HandleFunc("/1.1/account/verify_credentials.json", func(w http.ResponseWriter, r *http.Request) { 16 | assertMethod(t, "GET", r) 17 | assertQuery(t, map[string]string{"include_entities": "false", "include_email": "true"}, r) 18 | w.Header().Set("Content-Type", "application/json") 19 | fmt.Fprintf(w, `{"name": "Dalton Hubble", "id": 623265148}`) 20 | }) 21 | 22 | client := NewClient(httpClient) 23 | user, _, err := client.Accounts.VerifyCredentials(&AccountVerifyParams{IncludeEntities: Bool(false), IncludeEmail: Bool(true)}) 24 | expected := &User{Name: "Dalton Hubble", ID: 623265148} 25 | assert.Nil(t, err) 26 | assert.Equal(t, expected, user) 27 | } 28 | func TestAccountService_UpdateProfile(t *testing.T) { 29 | httpClient, mux, server := testServer() 30 | defer server.Close() 31 | 32 | mux.HandleFunc("/1.1/account/update_profile.json", func(w http.ResponseWriter, r *http.Request) { 33 | assertMethod(t, "POST", r) 34 | assertQuery(t, map[string]string{"location": "anywhere"}, r) 35 | w.Header().Set("Content-Type", "application/json") 36 | fmt.Fprintf(w, `{"name": "xkcdComic", "location":"anywhere"}`) 37 | }) 38 | 39 | client := NewClient(httpClient) 40 | params := &AccountUpdateProfileParams{Location: "anywhere"} 41 | user, _, err := client.Accounts.UpdateProfile(params) 42 | expected := &User{Name: "xkcdComic", Location: "anywhere"} 43 | assert.Nil(t, err) 44 | assert.Equal(t, expected, user) 45 | } 46 | -------------------------------------------------------------------------------- /twitter/backoffs.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/cenkalti/backoff/v4" 7 | ) 8 | 9 | func newExponentialBackOff() *backoff.ExponentialBackOff { 10 | b := backoff.NewExponentialBackOff() 11 | b.InitialInterval = 5 * time.Second 12 | b.Multiplier = 2.0 13 | b.MaxInterval = 320 * time.Second 14 | b.Reset() 15 | return b 16 | } 17 | 18 | func newAggressiveExponentialBackOff() *backoff.ExponentialBackOff { 19 | b := backoff.NewExponentialBackOff() 20 | b.InitialInterval = 1 * time.Minute 21 | b.Multiplier = 2.0 22 | b.MaxInterval = 16 * time.Minute 23 | b.Reset() 24 | return b 25 | } 26 | -------------------------------------------------------------------------------- /twitter/backoffs_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNewExponentialBackOff(t *testing.T) { 11 | b := newExponentialBackOff() 12 | assert.Equal(t, 5*time.Second, b.InitialInterval) 13 | assert.Equal(t, 2.0, b.Multiplier) 14 | assert.Equal(t, 320*time.Second, b.MaxInterval) 15 | } 16 | 17 | func TestNewAggressiveExponentialBackOff(t *testing.T) { 18 | b := newAggressiveExponentialBackOff() 19 | assert.Equal(t, 1*time.Minute, b.InitialInterval) 20 | assert.Equal(t, 2.0, b.Multiplier) 21 | assert.Equal(t, 16*time.Minute, b.MaxInterval) 22 | } 23 | 24 | // BackoffRecorder is an implementation of backoff.BackOff that records 25 | // calls to NextBackOff and Reset for later inspection in tests. 26 | type BackOffRecorder struct { 27 | Count int 28 | } 29 | 30 | func (b *BackOffRecorder) NextBackOff() time.Duration { 31 | b.Count++ 32 | return 1 * time.Nanosecond 33 | } 34 | 35 | func (b *BackOffRecorder) Reset() { 36 | b.Count = 0 37 | } 38 | -------------------------------------------------------------------------------- /twitter/block.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/dghubble/sling" 7 | ) 8 | 9 | // BlockService provides methods for blocking specific user. 10 | type BlockService struct { 11 | sling *sling.Sling 12 | } 13 | 14 | // newBlockService returns a new BlockService. 15 | func newBlockService(sling *sling.Sling) *BlockService { 16 | return &BlockService{ 17 | sling: sling.Path("blocks/"), 18 | } 19 | } 20 | 21 | // BlockCreateParams are the parameters for BlockService.Create. 22 | type BlockCreateParams struct { 23 | ScreenName string `url:"screen_name,omitempty,comma"` 24 | UserID int64 `url:"user_id,omitempty,comma"` 25 | IncludeEntities *bool `url:"include_entities,omitempty"` 26 | SkipStatus *bool `url:"skip_status,omitempty"` 27 | } 28 | 29 | // Create blocks a specific user and returns the blocked user. 30 | // https://developer.twitter.com/en/docs/accounts-and-users/mute-block-report-users/api-reference/post-blocks-create 31 | func (s *BlockService) Create(params *BlockCreateParams) (User, *http.Response, error) { 32 | users := new(User) 33 | apiError := new(APIError) 34 | resp, err := s.sling.New().Post("create.json").QueryStruct(params).Receive(users, apiError) 35 | return *users, resp, relevantError(err, *apiError) 36 | } 37 | 38 | // BlockDestroyParams are the parameters for BlockService.Destroy. 39 | type BlockDestroyParams struct { 40 | ScreenName string `url:"screen_name,omitempty,comma"` 41 | UserID int64 `url:"user_id,omitempty,comma"` 42 | IncludeEntities *bool `url:"include_entities,omitempty"` 43 | SkipStatus *bool `url:"skip_status,omitempty"` 44 | } 45 | 46 | // Destroy blocks a specific user and returns the unblocked user. 47 | // https://developer.twitter.com/en/docs/accounts-and-users/mute-block-report-users/api-reference/post-blocks-destroy 48 | func (s *BlockService) Destroy(params *BlockDestroyParams) (User, *http.Response, error) { 49 | users := new(User) 50 | apiError := new(APIError) 51 | resp, err := s.sling.New().Post("destroy.json").QueryStruct(params).Receive(users, apiError) 52 | return *users, resp, relevantError(err, *apiError) 53 | } 54 | -------------------------------------------------------------------------------- /twitter/block_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestBlockService_CreateService(t *testing.T) { 12 | httpClient, mux, server := testServer() 13 | defer server.Close() 14 | 15 | mux.HandleFunc("/1.1/blocks/create.json", func(w http.ResponseWriter, r *http.Request) { 16 | assertMethod(t, "POST", r) 17 | assertQuery(t, map[string]string{"screen_name": "golang"}, r) 18 | w.Header().Set("Content-Type", "application/json") 19 | fmt.Fprintf(w, `{"screen_name": "golang"}`) 20 | }) 21 | 22 | client := NewClient(httpClient) 23 | users, _, err := client.Blocks.Create(&BlockCreateParams{ScreenName: "golang"}) 24 | expected := User{ScreenName: "golang"} 25 | assert.Nil(t, err) 26 | assert.Equal(t, expected, users) 27 | } 28 | 29 | func TestBlockService_DestroyService(t *testing.T) { 30 | httpClient, mux, server := testServer() 31 | defer server.Close() 32 | 33 | mux.HandleFunc("/1.1/blocks/destroy.json", func(w http.ResponseWriter, r *http.Request) { 34 | assertMethod(t, "POST", r) 35 | assertQuery(t, map[string]string{"screen_name": "golang"}, r) 36 | w.Header().Set("Content-Type", "application/json") 37 | fmt.Fprintf(w, `{"screen_name": "golang"}`) 38 | }) 39 | 40 | client := NewClient(httpClient) 41 | users, _, err := client.Blocks.Destroy(&BlockDestroyParams{ScreenName: "golang"}) 42 | expected := User{ScreenName: "golang"} 43 | assert.Nil(t, err) 44 | assert.Equal(t, expected, users) 45 | } 46 | -------------------------------------------------------------------------------- /twitter/demux.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | // A Demux receives interface{} messages individually or from a channel and 4 | // sends those messages to one or more outputs determined by the 5 | // implementation. 6 | type Demux interface { 7 | Handle(message interface{}) 8 | HandleChan(messages <-chan interface{}) 9 | } 10 | 11 | // SwitchDemux receives messages and uses a type switch to send each typed 12 | // message to a handler function. 13 | type SwitchDemux struct { 14 | All func(message interface{}) 15 | Tweet func(tweet *Tweet) 16 | DM func(dm *DirectMessage) 17 | StatusDeletion func(deletion *StatusDeletion) 18 | LocationDeletion func(LocationDeletion *LocationDeletion) 19 | StreamLimit func(limit *StreamLimit) 20 | StatusWithheld func(statusWithheld *StatusWithheld) 21 | UserWithheld func(userWithheld *UserWithheld) 22 | StreamDisconnect func(disconnect *StreamDisconnect) 23 | Warning func(warning *StallWarning) 24 | FriendsList func(friendsList *FriendsList) 25 | Event func(event *Event) 26 | Other func(message interface{}) 27 | } 28 | 29 | // NewSwitchDemux returns a new SwitchMux which has NoOp handler functions. 30 | func NewSwitchDemux() SwitchDemux { 31 | return SwitchDemux{ 32 | All: func(message interface{}) {}, 33 | Tweet: func(tweet *Tweet) {}, 34 | DM: func(dm *DirectMessage) {}, 35 | StatusDeletion: func(deletion *StatusDeletion) {}, 36 | LocationDeletion: func(LocationDeletion *LocationDeletion) {}, 37 | StreamLimit: func(limit *StreamLimit) {}, 38 | StatusWithheld: func(statusWithheld *StatusWithheld) {}, 39 | UserWithheld: func(userWithheld *UserWithheld) {}, 40 | StreamDisconnect: func(disconnect *StreamDisconnect) {}, 41 | Warning: func(warning *StallWarning) {}, 42 | FriendsList: func(friendsList *FriendsList) {}, 43 | Event: func(event *Event) {}, 44 | Other: func(message interface{}) {}, 45 | } 46 | } 47 | 48 | // Handle determines the type of a message and calls the corresponding receiver 49 | // function with the typed message. All messages are passed to the All func. 50 | // Messages with unmatched types are passed to the Other func. 51 | func (d SwitchDemux) Handle(message interface{}) { 52 | d.All(message) 53 | switch msg := message.(type) { 54 | case *Tweet: 55 | d.Tweet(msg) 56 | case *DirectMessage: 57 | d.DM(msg) 58 | case *StatusDeletion: 59 | d.StatusDeletion(msg) 60 | case *LocationDeletion: 61 | d.LocationDeletion(msg) 62 | case *StreamLimit: 63 | d.StreamLimit(msg) 64 | case *StatusWithheld: 65 | d.StatusWithheld(msg) 66 | case *UserWithheld: 67 | d.UserWithheld(msg) 68 | case *StreamDisconnect: 69 | d.StreamDisconnect(msg) 70 | case *StallWarning: 71 | d.Warning(msg) 72 | case *FriendsList: 73 | d.FriendsList(msg) 74 | case *Event: 75 | d.Event(msg) 76 | default: 77 | d.Other(msg) 78 | } 79 | } 80 | 81 | // HandleChan receives messages and calls the corresponding receiver function 82 | // with the typed message. All messages are passed to the All func. Messages 83 | // with unmatched type are passed to the Other func. 84 | func (d SwitchDemux) HandleChan(messages <-chan interface{}) { 85 | for message := range messages { 86 | d.Handle(message) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /twitter/demux_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestDemux_Handle(t *testing.T) { 10 | messages, expectedCounts := exampleMessages() 11 | counts := &counter{} 12 | demux := newCounterDemux(counts) 13 | for _, message := range messages { 14 | demux.Handle(message) 15 | } 16 | assert.Equal(t, expectedCounts, counts) 17 | } 18 | 19 | func TestDemux_HandleChan(t *testing.T) { 20 | messages, expectedCounts := exampleMessages() 21 | counts := &counter{} 22 | demux := newCounterDemux(counts) 23 | ch := make(chan interface{}) 24 | // stream messages into channel 25 | go func() { 26 | for _, msg := range messages { 27 | ch <- msg 28 | } 29 | close(ch) 30 | }() 31 | // handle channel messages until exhausted 32 | demux.HandleChan(ch) 33 | assert.Equal(t, expectedCounts, counts) 34 | } 35 | 36 | // counter counts stream messages by type for testing. 37 | type counter struct { 38 | all int 39 | tweet int 40 | dm int 41 | statusDeletion int 42 | locationDeletion int 43 | streamLimit int 44 | statusWithheld int 45 | userWithheld int 46 | streamDisconnect int 47 | stallWarning int 48 | friendsList int 49 | event int 50 | other int 51 | } 52 | 53 | // newCounterDemux returns a Demux which counts message types. 54 | func newCounterDemux(counter *counter) Demux { 55 | demux := NewSwitchDemux() 56 | demux.All = func(interface{}) { 57 | counter.all++ 58 | } 59 | demux.Tweet = func(*Tweet) { 60 | counter.tweet++ 61 | } 62 | demux.DM = func(*DirectMessage) { 63 | counter.dm++ 64 | } 65 | demux.StatusDeletion = func(*StatusDeletion) { 66 | counter.statusDeletion++ 67 | } 68 | demux.LocationDeletion = func(*LocationDeletion) { 69 | counter.locationDeletion++ 70 | } 71 | demux.StreamLimit = func(*StreamLimit) { 72 | counter.streamLimit++ 73 | } 74 | demux.StatusWithheld = func(*StatusWithheld) { 75 | counter.statusWithheld++ 76 | } 77 | demux.UserWithheld = func(*UserWithheld) { 78 | counter.userWithheld++ 79 | } 80 | demux.StreamDisconnect = func(*StreamDisconnect) { 81 | counter.streamDisconnect++ 82 | } 83 | demux.Warning = func(*StallWarning) { 84 | counter.stallWarning++ 85 | } 86 | demux.FriendsList = func(*FriendsList) { 87 | counter.friendsList++ 88 | } 89 | demux.Event = func(*Event) { 90 | counter.event++ 91 | } 92 | demux.Other = func(interface{}) { 93 | counter.other++ 94 | } 95 | return demux 96 | } 97 | 98 | // examples messages returns a test stream of messages and the expected 99 | // counts of each message type. 100 | func exampleMessages() (messages []interface{}, expectedCounts *counter) { 101 | var ( 102 | tweet = &Tweet{} 103 | dm = &DirectMessage{} 104 | statusDeletion = &StatusDeletion{} 105 | locationDeletion = &LocationDeletion{} 106 | streamLimit = &StreamLimit{} 107 | statusWithheld = &StatusWithheld{} 108 | userWithheld = &UserWithheld{} 109 | streamDisconnect = &StreamDisconnect{} 110 | stallWarning = &StallWarning{} 111 | friendsList = &FriendsList{} 112 | event = &Event{} 113 | otherA = func() {} 114 | otherB = struct{}{} 115 | ) 116 | messages = []interface{}{tweet, dm, statusDeletion, locationDeletion, 117 | streamLimit, statusWithheld, userWithheld, streamDisconnect, 118 | stallWarning, friendsList, event, otherA, otherB} 119 | expectedCounts = &counter{ 120 | all: len(messages), 121 | tweet: 1, 122 | dm: 1, 123 | statusDeletion: 1, 124 | locationDeletion: 1, 125 | streamLimit: 1, 126 | statusWithheld: 1, 127 | userWithheld: 1, 128 | streamDisconnect: 1, 129 | stallWarning: 1, 130 | friendsList: 1, 131 | event: 1, 132 | other: 2, 133 | } 134 | return messages, expectedCounts 135 | } 136 | -------------------------------------------------------------------------------- /twitter/direct_messages.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/dghubble/sling" 8 | ) 9 | 10 | // DirectMessageEvents lists Direct Message events. 11 | type DirectMessageEvents struct { 12 | Events []DirectMessageEvent `json:"events"` 13 | NextCursor string `json:"next_cursor"` 14 | } 15 | 16 | // DirectMessageEvent is a single Direct Message sent or received. 17 | type DirectMessageEvent struct { 18 | CreatedAt string `json:"created_timestamp,omitempty"` 19 | ID string `json:"id,omitempty"` 20 | Type string `json:"type"` 21 | Message *DirectMessageEventMessage `json:"message_create"` 22 | } 23 | 24 | // DirectMessageEventMessage contains message contents, along with sender and 25 | // target recipient. 26 | type DirectMessageEventMessage struct { 27 | SenderID string `json:"sender_id,omitempty"` 28 | Target *DirectMessageTarget `json:"target"` 29 | Data *DirectMessageData `json:"message_data"` 30 | } 31 | 32 | // DirectMessageTarget specifies the recipient of a Direct Message event. 33 | type DirectMessageTarget struct { 34 | RecipientID string `json:"recipient_id"` 35 | } 36 | 37 | // DirectMessageData is the message data contained in a Direct Message event. 38 | type DirectMessageData struct { 39 | Text string `json:"text"` 40 | Entities *Entities `json:"entities,omitempty"` 41 | Attachment *DirectMessageDataAttachment `json:"attachment,omitempty"` 42 | QuickReply *DirectMessageQuickReply `json:"quick_reply,omitempty"` 43 | CTAs []DirectMessageCTA `json:"ctas,omitempty"` 44 | } 45 | 46 | // DirectMessageDataAttachment contains message data attachments for a Direct 47 | // Message event. 48 | type DirectMessageDataAttachment struct { 49 | Type string `json:"type"` 50 | Media MediaEntity `json:"media"` 51 | } 52 | 53 | // DirectMessageQuickReply contains quick reply data for a Direct Message 54 | // event. 55 | type DirectMessageQuickReply struct { 56 | Type string `json:"type"` 57 | Options []DirectMessageQuickReplyOption `json:"options"` 58 | } 59 | 60 | // DirectMessageQuickReplyOption represents Option object for 61 | // a Direct Message's Quick Reply. 62 | type DirectMessageQuickReplyOption struct { 63 | Label string `json:"label"` 64 | Description string `json:"description,omitempty"` 65 | Metadata string `json:"metadata,omitempty"` 66 | } 67 | 68 | // DirectMessageCTA contains CTA data for a Direct Message event. 69 | type DirectMessageCTA struct { 70 | Type string `json:"type"` 71 | Label string `json:"label"` 72 | URL string `json:"url"` 73 | } 74 | 75 | // DirectMessageService provides methods for accessing Twitter direct message 76 | // API endpoints. 77 | type DirectMessageService struct { 78 | baseSling *sling.Sling 79 | sling *sling.Sling 80 | } 81 | 82 | // newDirectMessageService returns a new DirectMessageService. 83 | func newDirectMessageService(sling *sling.Sling) *DirectMessageService { 84 | return &DirectMessageService{ 85 | baseSling: sling.New(), 86 | sling: sling.Path("direct_messages/"), 87 | } 88 | } 89 | 90 | // DirectMessageEventsNewParams are the parameters for 91 | // DirectMessageService.EventsNew 92 | type DirectMessageEventsNewParams struct { 93 | Event *DirectMessageEvent `json:"event"` 94 | } 95 | 96 | // EventsNew publishes a new Direct Message event and returns the event. 97 | // Requires a user auth context with DM scope. 98 | // https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/new-event 99 | func (s *DirectMessageService) EventsNew(params *DirectMessageEventsNewParams) (*DirectMessageEvent, *http.Response, error) { 100 | // Twitter API wraps the event response 101 | wrap := &struct { 102 | Event *DirectMessageEvent `json:"event"` 103 | }{} 104 | apiError := new(APIError) 105 | resp, err := s.sling.New().Post("events/new.json").BodyJSON(params).Receive(wrap, apiError) 106 | return wrap.Event, resp, relevantError(err, *apiError) 107 | } 108 | 109 | // DirectMessageEventsShowParams are the parameters for 110 | // DirectMessageService.EventsShow 111 | type DirectMessageEventsShowParams struct { 112 | ID string `url:"id,omitempty"` 113 | } 114 | 115 | // EventsShow returns a single Direct Message event by id. 116 | // Requires a user auth context with DM scope. 117 | // https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/get-event 118 | func (s *DirectMessageService) EventsShow(id string, params *DirectMessageEventsShowParams) (*DirectMessageEvent, *http.Response, error) { 119 | if params == nil { 120 | params = &DirectMessageEventsShowParams{} 121 | } 122 | params.ID = id 123 | // Twitter API wraps the event response 124 | wrap := &struct { 125 | Event *DirectMessageEvent `json:"event"` 126 | }{} 127 | apiError := new(APIError) 128 | resp, err := s.sling.New().Get("events/show.json").QueryStruct(params).Receive(wrap, apiError) 129 | return wrap.Event, resp, relevantError(err, *apiError) 130 | } 131 | 132 | // DirectMessageEventsListParams are the parameters for 133 | // DirectMessageService.EventsList 134 | type DirectMessageEventsListParams struct { 135 | Cursor string `url:"cursor,omitempty"` 136 | Count int `url:"count,omitempty"` 137 | } 138 | 139 | // EventsList returns Direct Message events (both sent and received) within 140 | // the last 30 days in reverse chronological order. 141 | // Requires a user auth context with DM scope. 142 | // https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/list-events 143 | func (s *DirectMessageService) EventsList(params *DirectMessageEventsListParams) (*DirectMessageEvents, *http.Response, error) { 144 | events := new(DirectMessageEvents) 145 | apiError := new(APIError) 146 | resp, err := s.sling.New().Get("events/list.json").QueryStruct(params).Receive(events, apiError) 147 | return events, resp, relevantError(err, *apiError) 148 | } 149 | 150 | // EventsDestroy deletes the Direct Message event by id. 151 | // Requires a user auth context with DM scope. 152 | // https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/delete-message-event 153 | func (s *DirectMessageService) EventsDestroy(id string) (*http.Response, error) { 154 | params := struct { 155 | ID string `url:"id,omitempty"` 156 | }{id} 157 | apiError := new(APIError) 158 | resp, err := s.sling.New().Delete("events/destroy.json").QueryStruct(params).Receive(nil, apiError) 159 | return resp, relevantError(err, *apiError) 160 | } 161 | 162 | // DEPRECATED 163 | 164 | // DirectMessage is a direct message to a single recipient (DEPRECATED). 165 | type DirectMessage struct { 166 | CreatedAt string `json:"created_at"` 167 | Entities *Entities `json:"entities"` 168 | ID int64 `json:"id"` 169 | IDStr string `json:"id_str"` 170 | Recipient *User `json:"recipient"` 171 | RecipientID int64 `json:"recipient_id"` 172 | RecipientScreenName string `json:"recipient_screen_name"` 173 | Sender *User `json:"sender"` 174 | SenderID int64 `json:"sender_id"` 175 | SenderScreenName string `json:"sender_screen_name"` 176 | Text string `json:"text"` 177 | } 178 | 179 | // CreatedAtTime returns the time a Direct Message was created (DEPRECATED). 180 | func (d DirectMessage) CreatedAtTime() (time.Time, error) { 181 | return time.Parse(time.RubyDate, d.CreatedAt) 182 | } 183 | 184 | // directMessageShowParams are the parameters for DirectMessageService.Show 185 | type directMessageShowParams struct { 186 | ID int64 `url:"id,omitempty"` 187 | } 188 | 189 | // Show returns the requested Direct Message (DEPRECATED). 190 | // Requires a user auth context with DM scope. 191 | // https://dev.twitter.com/rest/reference/get/direct_messages/show 192 | func (s *DirectMessageService) Show(id int64) (*DirectMessage, *http.Response, error) { 193 | params := &directMessageShowParams{ID: id} 194 | dm := new(DirectMessage) 195 | apiError := new(APIError) 196 | resp, err := s.sling.New().Get("show.json").QueryStruct(params).Receive(dm, apiError) 197 | return dm, resp, relevantError(err, *apiError) 198 | } 199 | 200 | // DirectMessageGetParams are the parameters for DirectMessageService.Get 201 | // (DEPRECATED). 202 | type DirectMessageGetParams struct { 203 | SinceID int64 `url:"since_id,omitempty"` 204 | MaxID int64 `url:"max_id,omitempty"` 205 | Count int `url:"count,omitempty"` 206 | IncludeEntities *bool `url:"include_entities,omitempty"` 207 | SkipStatus *bool `url:"skip_status,omitempty"` 208 | } 209 | 210 | // Get returns recent Direct Messages received by the authenticated user 211 | // (DEPRECATED). 212 | // Requires a user auth context with DM scope. 213 | // https://dev.twitter.com/rest/reference/get/direct_messages 214 | func (s *DirectMessageService) Get(params *DirectMessageGetParams) ([]DirectMessage, *http.Response, error) { 215 | dms := new([]DirectMessage) 216 | apiError := new(APIError) 217 | resp, err := s.baseSling.New().Get("direct_messages.json").QueryStruct(params).Receive(dms, apiError) 218 | return *dms, resp, relevantError(err, *apiError) 219 | } 220 | 221 | // DirectMessageSentParams are the parameters for DirectMessageService.Sent 222 | // (DEPRECATED). 223 | type DirectMessageSentParams struct { 224 | SinceID int64 `url:"since_id,omitempty"` 225 | MaxID int64 `url:"max_id,omitempty"` 226 | Count int `url:"count,omitempty"` 227 | Page int `url:"page,omitempty"` 228 | IncludeEntities *bool `url:"include_entities,omitempty"` 229 | } 230 | 231 | // Sent returns recent Direct Messages sent by the authenticated user 232 | // (DEPRECATED). 233 | // Requires a user auth context with DM scope. 234 | // https://dev.twitter.com/rest/reference/get/direct_messages/sent 235 | func (s *DirectMessageService) Sent(params *DirectMessageSentParams) ([]DirectMessage, *http.Response, error) { 236 | dms := new([]DirectMessage) 237 | apiError := new(APIError) 238 | resp, err := s.sling.New().Get("sent.json").QueryStruct(params).Receive(dms, apiError) 239 | return *dms, resp, relevantError(err, *apiError) 240 | } 241 | 242 | // DirectMessageNewParams are the parameters for DirectMessageService.New 243 | // (DEPRECATED). 244 | type DirectMessageNewParams struct { 245 | UserID int64 `url:"user_id,omitempty"` 246 | ScreenName string `url:"screen_name,omitempty"` 247 | Text string `url:"text"` 248 | } 249 | 250 | // New sends a new Direct Message to a specified user as the authenticated 251 | // user (DEPRECATED). 252 | // Requires a user auth context with DM scope. 253 | // https://dev.twitter.com/rest/reference/post/direct_messages/new 254 | func (s *DirectMessageService) New(params *DirectMessageNewParams) (*DirectMessage, *http.Response, error) { 255 | dm := new(DirectMessage) 256 | apiError := new(APIError) 257 | resp, err := s.sling.New().Post("new.json").BodyForm(params).Receive(dm, apiError) 258 | return dm, resp, relevantError(err, *apiError) 259 | } 260 | 261 | // DirectMessageDestroyParams are the parameters for DirectMessageService.Destroy 262 | // (DEPRECATED). 263 | type DirectMessageDestroyParams struct { 264 | ID int64 `url:"id,omitempty"` 265 | IncludeEntities *bool `url:"include_entities,omitempty"` 266 | } 267 | 268 | // Destroy deletes the Direct Message with the given id and returns it if 269 | // successful (DEPRECATED). 270 | // Requires a user auth context with DM scope. 271 | // https://dev.twitter.com/rest/reference/post/direct_messages/destroy 272 | func (s *DirectMessageService) Destroy(id int64, params *DirectMessageDestroyParams) (*DirectMessage, *http.Response, error) { 273 | if params == nil { 274 | params = &DirectMessageDestroyParams{} 275 | } 276 | params.ID = id 277 | dm := new(DirectMessage) 278 | apiError := new(APIError) 279 | resp, err := s.sling.New().Post("destroy.json").BodyForm(params).Receive(dm, apiError) 280 | return dm, resp, relevantError(err, *apiError) 281 | } 282 | -------------------------------------------------------------------------------- /twitter/direct_messages_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | var ( 12 | testDMEvent = DirectMessageEvent{ 13 | CreatedAt: "1542410751275", 14 | ID: "1063573894173323269", 15 | Type: "message_create", 16 | Message: &DirectMessageEventMessage{ 17 | SenderID: "623265148", 18 | Target: &DirectMessageTarget{ 19 | RecipientID: "3694959333", 20 | }, 21 | Data: &DirectMessageData{ 22 | Text: "example", 23 | Entities: &Entities{ 24 | Hashtags: []HashtagEntity{}, 25 | Urls: []URLEntity{}, 26 | UserMentions: []MentionEntity{}, 27 | Symbols: []SymbolEntity{ 28 | {Indices: Indices{38, 43}, Text: "TWTR"}, 29 | }, 30 | }, 31 | }, 32 | }, 33 | } 34 | testDMEventID = "1063573894173323269" 35 | testDMEventJSON = ` 36 | { 37 | "type": "message_create", 38 | "id": "1063573894173323269", 39 | "created_timestamp": "1542410751275", 40 | "message_create": { 41 | "target": { 42 | "recipient_id": "3694959333" 43 | }, 44 | "sender_id": "623265148", 45 | "message_data": { 46 | "text": "example", 47 | "entities": { 48 | "hashtags": [], 49 | "symbols": [{"indices": [38,43], "text":"TWTR"}], 50 | "user_mentions": [], 51 | "urls": [] 52 | } 53 | } 54 | } 55 | }` 56 | testDMEventShowJSON = `{"event": ` + testDMEventJSON + `}` 57 | testDMEventListJSON = `{"events": [` + testDMEventJSON + `], "next_cursor": "AB345dkfC"}` 58 | testDMEventNewInputJSON = `{"event":{"type":"message_create","message_create":{"target":{"recipient_id":"3694959333"},"message_data":{"text":"example"}}}} 59 | ` 60 | // DEPRECATED 61 | testDM = DirectMessage{ 62 | ID: 240136858829479936, 63 | Recipient: &User{ScreenName: "theSeanCook"}, 64 | Sender: &User{ScreenName: "s0c1alm3dia"}, 65 | Text: "hello world", 66 | } 67 | testDMIDStr = "240136858829479936" 68 | testDMJSON = `{"id": 240136858829479936,"recipient": {"screen_name": "theSeanCook"},"sender": {"screen_name": "s0c1alm3dia"},"text": "hello world"}` 69 | ) 70 | 71 | func TestDirectMessageService_EventsNew(t *testing.T) { 72 | httpClient, mux, server := testServer() 73 | defer server.Close() 74 | 75 | mux.HandleFunc("/1.1/direct_messages/events/new.json", func(w http.ResponseWriter, r *http.Request) { 76 | assertMethod(t, "POST", r) 77 | assertPostJSON(t, testDMEventNewInputJSON, r) 78 | w.Header().Set("Content-Type", "application/json") 79 | fmt.Fprint(w, testDMEventShowJSON) 80 | }) 81 | 82 | client := NewClient(httpClient) 83 | event, _, err := client.DirectMessages.EventsNew(&DirectMessageEventsNewParams{ 84 | Event: &DirectMessageEvent{ 85 | Type: "message_create", 86 | Message: &DirectMessageEventMessage{ 87 | Target: &DirectMessageTarget{ 88 | RecipientID: "3694959333", 89 | }, 90 | Data: &DirectMessageData{ 91 | Text: "example", 92 | }, 93 | }, 94 | }, 95 | }) 96 | assert.Nil(t, err) 97 | assert.Equal(t, &testDMEvent, event) 98 | } 99 | 100 | func TestDirectMessageService_EventsShow(t *testing.T) { 101 | httpClient, mux, server := testServer() 102 | defer server.Close() 103 | 104 | mux.HandleFunc("/1.1/direct_messages/events/show.json", func(w http.ResponseWriter, r *http.Request) { 105 | assertMethod(t, "GET", r) 106 | assertQuery(t, map[string]string{"id": testDMEventID}, r) 107 | w.Header().Set("Content-Type", "application/json") 108 | fmt.Fprint(w, testDMEventShowJSON) 109 | }) 110 | 111 | client := NewClient(httpClient) 112 | event, _, err := client.DirectMessages.EventsShow(testDMEventID, nil) 113 | assert.Nil(t, err) 114 | assert.Equal(t, &testDMEvent, event) 115 | } 116 | 117 | func TestDirectMessageService_EventsList(t *testing.T) { 118 | httpClient, mux, server := testServer() 119 | defer server.Close() 120 | 121 | mux.HandleFunc("/1.1/direct_messages/events/list.json", func(w http.ResponseWriter, r *http.Request) { 122 | assertMethod(t, "GET", r) 123 | assertQuery(t, map[string]string{"count": "10"}, r) 124 | w.Header().Set("Content-Type", "application/json") 125 | fmt.Fprint(w, testDMEventListJSON) 126 | }) 127 | expected := &DirectMessageEvents{ 128 | Events: []DirectMessageEvent{testDMEvent}, 129 | NextCursor: "AB345dkfC", 130 | } 131 | 132 | client := NewClient(httpClient) 133 | events, _, err := client.DirectMessages.EventsList(&DirectMessageEventsListParams{Count: 10}) 134 | assert.Equal(t, expected, events) 135 | assert.Nil(t, err) 136 | } 137 | 138 | func TestDirectMessageService_EventsDestroy(t *testing.T) { 139 | httpClient, mux, server := testServer() 140 | defer server.Close() 141 | 142 | mux.HandleFunc("/1.1/direct_messages/events/destroy.json", func(w http.ResponseWriter, r *http.Request) { 143 | assertMethod(t, "DELETE", r) 144 | assertQuery(t, map[string]string{"id": testDMEventID}, r) 145 | w.Header().Set("Content-Type", "application/json") 146 | // successful delete returns 204 No Content 147 | w.WriteHeader(204) 148 | }) 149 | 150 | client := NewClient(httpClient) 151 | resp, err := client.DirectMessages.EventsDestroy(testDMEventID) 152 | assert.Nil(t, err) 153 | assert.NotNil(t, resp) 154 | } 155 | 156 | func TestDirectMessageService_EventsDestroyError(t *testing.T) { 157 | httpClient, mux, server := testServer() 158 | defer server.Close() 159 | 160 | mux.HandleFunc("/1.1/direct_messages/events/destroy.json", func(w http.ResponseWriter, r *http.Request) { 161 | assertMethod(t, "DELETE", r) 162 | assertQuery(t, map[string]string{"id": testDMEventID}, r) 163 | w.Header().Set("Content-Type", "application/json") 164 | // failure to delete event that doesn't exist 165 | w.WriteHeader(404) 166 | fmt.Fprintf(w, `{"errors":[{"code": 34, "message": "Sorry, that page does not exist"}]}`) 167 | }) 168 | expected := APIError{ 169 | Errors: []ErrorDetail{ 170 | {Code: 34, Message: "Sorry, that page does not exist"}, 171 | }, 172 | } 173 | 174 | client := NewClient(httpClient) 175 | resp, err := client.DirectMessages.EventsDestroy(testDMEventID) 176 | assert.NotNil(t, resp) 177 | if assert.Error(t, err) { 178 | assert.Equal(t, expected, err) 179 | } 180 | } 181 | 182 | // DEPRECATED 183 | 184 | func TestDirectMessageService_Show(t *testing.T) { 185 | httpClient, mux, server := testServer() 186 | defer server.Close() 187 | 188 | mux.HandleFunc("/1.1/direct_messages/show.json", func(w http.ResponseWriter, r *http.Request) { 189 | assertMethod(t, "GET", r) 190 | assertQuery(t, map[string]string{"id": testDMIDStr}, r) 191 | w.Header().Set("Content-Type", "application/json") 192 | fmt.Fprint(w, testDMJSON) 193 | }) 194 | 195 | client := NewClient(httpClient) 196 | dms, _, err := client.DirectMessages.Show(testDM.ID) 197 | assert.Nil(t, err) 198 | assert.Equal(t, &testDM, dms) 199 | } 200 | 201 | func TestDirectMessageService_Get(t *testing.T) { 202 | httpClient, mux, server := testServer() 203 | defer server.Close() 204 | 205 | mux.HandleFunc("/1.1/direct_messages.json", func(w http.ResponseWriter, r *http.Request) { 206 | assertMethod(t, "GET", r) 207 | assertQuery(t, map[string]string{"since_id": "589147592367431680", "count": "1"}, r) 208 | w.Header().Set("Content-Type", "application/json") 209 | fmt.Fprintf(w, `[`+testDMJSON+`]`) 210 | }) 211 | 212 | client := NewClient(httpClient) 213 | params := &DirectMessageGetParams{SinceID: 589147592367431680, Count: 1} 214 | dms, _, err := client.DirectMessages.Get(params) 215 | expected := []DirectMessage{testDM} 216 | assert.Nil(t, err) 217 | assert.Equal(t, expected, dms) 218 | } 219 | 220 | func TestDirectMessageService_Sent(t *testing.T) { 221 | httpClient, mux, server := testServer() 222 | defer server.Close() 223 | 224 | mux.HandleFunc("/1.1/direct_messages/sent.json", func(w http.ResponseWriter, r *http.Request) { 225 | assertMethod(t, "GET", r) 226 | assertQuery(t, map[string]string{"since_id": "589147592367431680", "count": "1"}, r) 227 | w.Header().Set("Content-Type", "application/json") 228 | fmt.Fprintf(w, `[`+testDMJSON+`]`) 229 | }) 230 | 231 | client := NewClient(httpClient) 232 | params := &DirectMessageSentParams{SinceID: 589147592367431680, Count: 1} 233 | dms, _, err := client.DirectMessages.Sent(params) 234 | expected := []DirectMessage{testDM} 235 | assert.Nil(t, err) 236 | assert.Equal(t, expected, dms) 237 | } 238 | 239 | func TestDirectMessageService_New(t *testing.T) { 240 | httpClient, mux, server := testServer() 241 | defer server.Close() 242 | 243 | mux.HandleFunc("/1.1/direct_messages/new.json", func(w http.ResponseWriter, r *http.Request) { 244 | assertMethod(t, "POST", r) 245 | assertPostForm(t, map[string]string{"screen_name": "theseancook", "text": "hello world"}, r) 246 | w.Header().Set("Content-Type", "application/json") 247 | fmt.Fprint(w, testDMJSON) 248 | }) 249 | 250 | client := NewClient(httpClient) 251 | params := &DirectMessageNewParams{ScreenName: "theseancook", Text: "hello world"} 252 | dm, _, err := client.DirectMessages.New(params) 253 | assert.Nil(t, err) 254 | assert.Equal(t, &testDM, dm) 255 | } 256 | 257 | func TestDirectMessageService_Destroy(t *testing.T) { 258 | httpClient, mux, server := testServer() 259 | defer server.Close() 260 | 261 | mux.HandleFunc("/1.1/direct_messages/destroy.json", func(w http.ResponseWriter, r *http.Request) { 262 | assertMethod(t, "POST", r) 263 | assertPostForm(t, map[string]string{"id": testDMIDStr}, r) 264 | w.Header().Set("Content-Type", "application/json") 265 | fmt.Fprint(w, testDMJSON) 266 | }) 267 | 268 | client := NewClient(httpClient) 269 | dm, _, err := client.DirectMessages.Destroy(testDM.ID, nil) 270 | assert.Nil(t, err) 271 | assert.Equal(t, &testDM, dm) 272 | } 273 | -------------------------------------------------------------------------------- /twitter/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package twitter provides a Client for the Twitter API. 3 | 4 | Deprecated: This package will no longer be developed. 5 | 6 | The twitter package provides a Client for accessing the Twitter API. Here are 7 | some example requests. 8 | 9 | // Twitter client 10 | client := twitter.NewClient(httpClient) 11 | // Home Timeline 12 | tweets, resp, err := client.Timelines.HomeTimeline(&HomeTimelineParams{}) 13 | // Send a Tweet 14 | tweet, resp, err := client.Statuses.Update("just setting up my twttr", nil) 15 | // Status Show 16 | tweet, resp, err := client.Statuses.Show(585613041028431872, nil) 17 | // User Show 18 | params := &twitter.UserShowParams{ScreenName: "dghubble"} 19 | user, resp, err := client.Users.Show(params) 20 | // Followers 21 | followers, resp, err := client.Followers.List(&FollowerListParams{}) 22 | 23 | Required parameters are passed as positional arguments. Optional parameters 24 | are passed in a typed params struct (or pass nil). 25 | 26 | # Authentication 27 | 28 | By design, the Twitter Client accepts any http.Client so user auth (OAuth1) or 29 | application auth (OAuth2) requests can be made by using the appropriate 30 | authenticated client. Use the https://github.com/dghubble/oauth1 and 31 | https://github.com/golang/oauth2 packages to obtain an http.Client which 32 | transparently authorizes requests. 33 | 34 | For example, make requests as a consumer application on behalf of a user who 35 | has granted access, with OAuth1. 36 | 37 | // OAuth1 38 | import ( 39 | "github.com/dghubble/go-twitter/twitter" 40 | "github.com/dghubble/oauth1" 41 | ) 42 | 43 | config := oauth1.NewConfig("consumerKey", "consumerSecret") 44 | token := oauth1.NewToken("accessToken", "accessSecret") 45 | // http.Client will automatically authorize Requests 46 | httpClient := config.Client(oauth1.NoContext, token) 47 | 48 | // twitter client 49 | client := twitter.NewClient(httpClient) 50 | 51 | If no user auth context is needed, make requests as your application with 52 | application auth. 53 | 54 | // OAuth2 55 | import ( 56 | "github.com/dghubble/go-twitter/twitter" 57 | "golang.org/x/oauth2" 58 | "golang.org/x/oauth2/clientcredentials" 59 | ) 60 | 61 | // oauth2 configures a client that uses app credentials to keep a fresh token 62 | config := &clientcredentials.Config{ 63 | ClientID: flags.consumerKey, 64 | ClientSecret: flags.consumerSecret, 65 | TokenURL: "https://api.twitter.com/oauth2/token", 66 | } 67 | // http.Client will automatically authorize Requests 68 | httpClient := config.Client(oauth2.NoContext) 69 | 70 | // Twitter client 71 | client := twitter.NewClient(httpClient) 72 | 73 | To implement Login with Twitter, see https://github.com/dghubble/gologin. 74 | */ 75 | package twitter 76 | -------------------------------------------------------------------------------- /twitter/entities.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | // Entities represent metadata and context info parsed from Twitter components. 4 | // https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/entities-object 5 | type Entities struct { 6 | Hashtags []HashtagEntity `json:"hashtags"` 7 | Media []MediaEntity `json:"media"` 8 | Urls []URLEntity `json:"urls"` 9 | UserMentions []MentionEntity `json:"user_mentions"` 10 | Symbols []SymbolEntity `json:"symbols"` 11 | Polls []PollEntity `json:"polls"` 12 | } 13 | 14 | // HashtagEntity represents a hashtag which has been parsed from text. 15 | type HashtagEntity struct { 16 | Indices Indices `json:"indices"` 17 | Text string `json:"text"` 18 | } 19 | 20 | // URLEntity represents a URL which has been parsed from text. 21 | type URLEntity struct { 22 | URL string `json:"url"` 23 | DisplayURL string `json:"display_url"` 24 | ExpandedURL string `json:"expanded_url"` 25 | Unwound UnwoundURL `string:"unwound"` 26 | Indices Indices `json:"indices"` 27 | } 28 | 29 | // UnwoundURL represents an enhanced URL 30 | // https://developer.twitter.com/en/docs/twitter-api/enterprise/enrichments/overview/expanded-and-enhanced-urls 31 | type UnwoundURL struct { 32 | URL string `json:"url"` 33 | Status int `json:"status"` 34 | Title string `json:"title"` 35 | Description string `json:"description"` 36 | } 37 | 38 | // MediaEntity represents media elements associated with a Tweet. 39 | type MediaEntity struct { 40 | URLEntity 41 | ID int64 `json:"id"` 42 | IDStr string `json:"id_str"` 43 | MediaURL string `json:"media_url"` 44 | MediaURLHttps string `json:"media_url_https"` 45 | SourceStatusID int64 `json:"source_status_id"` 46 | SourceStatusIDStr string `json:"source_status_id_str"` 47 | Type string `json:"type"` 48 | Sizes MediaSizes `json:"sizes"` 49 | VideoInfo VideoInfo `json:"video_info"` 50 | } 51 | 52 | // MentionEntity represents Twitter user mentions parsed from text. 53 | type MentionEntity struct { 54 | Indices Indices `json:"indices"` 55 | ID int64 `json:"id"` 56 | IDStr string `json:"id_str"` 57 | Name string `json:"name"` 58 | ScreenName string `json:"screen_name"` 59 | } 60 | 61 | // SymbolEntity represents a symbol (e.g. $twtr) which has been parsed from text. 62 | // https://developer.twitter.com/en/docs/twitter-api/v1/data-dictionary/object-model/entities#symbols 63 | type SymbolEntity struct { 64 | Indices Indices `json:"indices"` 65 | Text string `json:"text"` 66 | } 67 | 68 | // PollEntity represents a Twitter Poll from a Tweet. 69 | // Note that poll metadata is only available with enterprise. 70 | // https://developer.twitter.com/en/docs/twitter-api/v1/data-dictionary/object-model/entities#polls 71 | type PollEntity struct { 72 | Options []PollOption `json:"options"` 73 | EndDateTime string `json:"end_datetime"` 74 | DurationMinutes string `json:"duration_minutes"` 75 | } 76 | 77 | // PollOption represents a position option in a PollEntity. 78 | type PollOption struct { 79 | Position int `json:"position"` 80 | Text string `json:"text"` 81 | } 82 | 83 | // UserEntities contain Entities parsed from User url and description fields. 84 | // https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/entities-object#mentions 85 | type UserEntities struct { 86 | URL Entities `json:"url"` 87 | Description Entities `json:"description"` 88 | } 89 | 90 | // ExtendedEntity contains media information. 91 | // https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/extended-entities-object 92 | type ExtendedEntity struct { 93 | Media []MediaEntity `json:"media"` 94 | } 95 | 96 | // Indices represent the start and end offsets within text. 97 | type Indices [2]int 98 | 99 | // Start returns the index at which an entity starts, inclusive. 100 | func (i Indices) Start() int { 101 | return i[0] 102 | } 103 | 104 | // End returns the index at which an entity ends, exclusive. 105 | func (i Indices) End() int { 106 | return i[1] 107 | } 108 | 109 | // MediaSizes contain the different size media that are available. 110 | // https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/entities-object#media-size 111 | type MediaSizes struct { 112 | Thumb MediaSize `json:"thumb"` 113 | Large MediaSize `json:"large"` 114 | Medium MediaSize `json:"medium"` 115 | Small MediaSize `json:"small"` 116 | } 117 | 118 | // MediaSize describes the height, width, and resizing method used. 119 | type MediaSize struct { 120 | Width int `json:"w"` 121 | Height int `json:"h"` 122 | Resize string `json:"resize"` 123 | } 124 | 125 | // VideoInfo is available on video media objects. 126 | type VideoInfo struct { 127 | AspectRatio [2]int `json:"aspect_ratio"` 128 | DurationMillis int `json:"duration_millis"` 129 | Variants []VideoVariant `json:"variants"` 130 | } 131 | 132 | // VideoVariant describes one of the available video formats. 133 | type VideoVariant struct { 134 | ContentType string `json:"content_type"` 135 | Bitrate int `json:"bitrate"` 136 | URL string `json:"url"` 137 | } 138 | -------------------------------------------------------------------------------- /twitter/entities_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestIndices(t *testing.T) { 10 | cases := []struct { 11 | pair Indices 12 | expectedStart int 13 | expectedEnd int 14 | }{ 15 | {Indices{}, 0, 0}, 16 | {Indices{25, 47}, 25, 47}, 17 | } 18 | for _, c := range cases { 19 | assert.Equal(t, c.expectedStart, c.pair.Start()) 20 | assert.Equal(t, c.expectedEnd, c.pair.End()) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /twitter/errors.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // APIError represents a Twitter API Error response 8 | // https://dev.twitter.com/overview/api/response-codes 9 | type APIError struct { 10 | Errors []ErrorDetail `json:"errors"` 11 | } 12 | 13 | // ErrorDetail represents an individual item in an APIError. 14 | type ErrorDetail struct { 15 | Message string `json:"message"` 16 | Code int `json:"code"` 17 | } 18 | 19 | func (e APIError) Error() string { 20 | if len(e.Errors) > 0 { 21 | err := e.Errors[0] 22 | return fmt.Sprintf("twitter: %d %v", err.Code, err.Message) 23 | } 24 | return "" 25 | } 26 | 27 | // Empty returns true if empty. Otherwise, at least 1 error message/code is 28 | // present and false is returned. 29 | func (e APIError) Empty() bool { 30 | return len(e.Errors) == 0 31 | } 32 | 33 | // relevantError returns any non-nil http-related error (creating the request, 34 | // getting the response, decoding) if any. If the decoded apiError is non-zero 35 | // the apiError is returned. Otherwise, no errors occurred, returns nil. 36 | func relevantError(httpError error, apiError APIError) error { 37 | if httpError != nil { 38 | return httpError 39 | } 40 | if apiError.Empty() { 41 | return nil 42 | } 43 | return apiError 44 | } 45 | -------------------------------------------------------------------------------- /twitter/errors_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | var errAPI = APIError{ 11 | Errors: []ErrorDetail{ 12 | {Message: "Status is a duplicate", Code: 187}, 13 | }, 14 | } 15 | var errHTTP = fmt.Errorf("unknown host") 16 | 17 | func TestAPIError_Error(t *testing.T) { 18 | err := APIError{} 19 | if assert.Error(t, err) { 20 | assert.Equal(t, "", err.Error()) 21 | } 22 | if assert.Error(t, errAPI) { 23 | assert.Equal(t, "twitter: 187 Status is a duplicate", errAPI.Error()) 24 | } 25 | } 26 | 27 | func TestAPIError_Empty(t *testing.T) { 28 | err := APIError{} 29 | assert.True(t, err.Empty()) 30 | assert.False(t, errAPI.Empty()) 31 | } 32 | 33 | func TestRelevantError(t *testing.T) { 34 | cases := []struct { 35 | httpError error 36 | apiError APIError 37 | expected error 38 | }{ 39 | {nil, APIError{}, nil}, 40 | {nil, errAPI, errAPI}, 41 | {errHTTP, APIError{}, errHTTP}, 42 | {errHTTP, errAPI, errHTTP}, 43 | } 44 | for _, c := range cases { 45 | err := relevantError(c.httpError, c.apiError) 46 | assert.Equal(t, c.expected, err) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /twitter/favorites.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/dghubble/sling" 7 | ) 8 | 9 | // FavoriteService provides methods for accessing Twitter favorite API endpoints. 10 | // 11 | // Note: the like action was known as favorite before November 3, 2015; the 12 | // historical naming remains in API methods and object properties. 13 | type FavoriteService struct { 14 | sling *sling.Sling 15 | } 16 | 17 | // newFavoriteService returns a new FavoriteService. 18 | func newFavoriteService(sling *sling.Sling) *FavoriteService { 19 | return &FavoriteService{ 20 | sling: sling.Path("favorites/"), 21 | } 22 | } 23 | 24 | // FavoriteListParams are the parameters for FavoriteService.List. 25 | type FavoriteListParams struct { 26 | UserID int64 `url:"user_id,omitempty"` 27 | ScreenName string `url:"screen_name,omitempty"` 28 | Count int `url:"count,omitempty"` 29 | SinceID int64 `url:"since_id,omitempty"` 30 | MaxID int64 `url:"max_id,omitempty"` 31 | IncludeEntities *bool `url:"include_entities,omitempty"` 32 | TweetMode string `url:"tweet_mode,omitempty"` 33 | } 34 | 35 | // List returns liked Tweets from the specified user. 36 | // https://dev.twitter.com/rest/reference/get/favorites/list 37 | func (s *FavoriteService) List(params *FavoriteListParams) ([]Tweet, *http.Response, error) { 38 | favorites := new([]Tweet) 39 | apiError := new(APIError) 40 | resp, err := s.sling.New().Get("list.json").QueryStruct(params).Receive(favorites, apiError) 41 | return *favorites, resp, relevantError(err, *apiError) 42 | } 43 | 44 | // FavoriteCreateParams are the parameters for FavoriteService.Create. 45 | type FavoriteCreateParams struct { 46 | ID int64 `url:"id,omitempty"` 47 | } 48 | 49 | // Create favorites the specified tweet. 50 | // Requires a user auth context. 51 | // https://dev.twitter.com/rest/reference/post/favorites/create 52 | func (s *FavoriteService) Create(params *FavoriteCreateParams) (*Tweet, *http.Response, error) { 53 | tweet := new(Tweet) 54 | apiError := new(APIError) 55 | resp, err := s.sling.New().Post("create.json").QueryStruct(params).Receive(tweet, apiError) 56 | return tweet, resp, relevantError(err, *apiError) 57 | } 58 | 59 | // FavoriteDestroyParams are the parameters for FavoriteService.Destroy. 60 | type FavoriteDestroyParams struct { 61 | ID int64 `url:"id,omitempty"` 62 | } 63 | 64 | // Destroy un-favorites the specified tweet. 65 | // Requires a user auth context. 66 | // https://dev.twitter.com/rest/reference/post/favorites/destroy 67 | func (s *FavoriteService) Destroy(params *FavoriteDestroyParams) (*Tweet, *http.Response, error) { 68 | tweet := new(Tweet) 69 | apiError := new(APIError) 70 | resp, err := s.sling.New().Post("destroy.json").QueryStruct(params).Receive(tweet, apiError) 71 | return tweet, resp, relevantError(err, *apiError) 72 | } 73 | -------------------------------------------------------------------------------- /twitter/favorites_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestFavoriteService_List(t *testing.T) { 12 | httpClient, mux, server := testServer() 13 | defer server.Close() 14 | 15 | mux.HandleFunc("/1.1/favorites/list.json", func(w http.ResponseWriter, r *http.Request) { 16 | assertMethod(t, "GET", r) 17 | assertQuery(t, map[string]string{"user_id": "113419064", "since_id": "101492475", "include_entities": "false"}, r) 18 | w.Header().Set("Content-Type", "application/json") 19 | fmt.Fprintf(w, `[{"text": "Gophercon talks!"}, {"text": "Why gophers are so adorable"}]`) 20 | }) 21 | 22 | client := NewClient(httpClient) 23 | tweets, _, err := client.Favorites.List(&FavoriteListParams{UserID: 113419064, SinceID: 101492475, IncludeEntities: Bool(false)}) 24 | expected := []Tweet{ 25 | {Text: "Gophercon talks!"}, 26 | {Text: "Why gophers are so adorable"}, 27 | } 28 | assert.Nil(t, err) 29 | assert.Equal(t, expected, tweets) 30 | } 31 | 32 | func TestFavoriteService_Create(t *testing.T) { 33 | httpClient, mux, server := testServer() 34 | defer server.Close() 35 | 36 | mux.HandleFunc("/1.1/favorites/create.json", func(w http.ResponseWriter, r *http.Request) { 37 | assertMethod(t, "POST", r) 38 | assertPostForm(t, map[string]string{"id": "12345"}, r) 39 | w.Header().Set("Content-Type", "application/json") 40 | fmt.Fprintf(w, `{"id": 581980947630845953, "text": "very informative tweet"}`) 41 | }) 42 | 43 | client := NewClient(httpClient) 44 | params := &FavoriteCreateParams{ID: 12345} 45 | tweet, _, err := client.Favorites.Create(params) 46 | assert.Nil(t, err) 47 | expected := &Tweet{ID: 581980947630845953, Text: "very informative tweet"} 48 | assert.Equal(t, expected, tweet) 49 | } 50 | 51 | func TestFavoriteService_Destroy(t *testing.T) { 52 | httpClient, mux, server := testServer() 53 | defer server.Close() 54 | 55 | mux.HandleFunc("/1.1/favorites/destroy.json", func(w http.ResponseWriter, r *http.Request) { 56 | assertMethod(t, "POST", r) 57 | assertPostForm(t, map[string]string{"id": "12345"}, r) 58 | w.Header().Set("Content-Type", "application/json") 59 | fmt.Fprintf(w, `{"id": 581980947630845953, "text": "very unhappy tweet"}`) 60 | }) 61 | 62 | client := NewClient(httpClient) 63 | params := &FavoriteDestroyParams{ID: 12345} 64 | tweet, _, err := client.Favorites.Destroy(params) 65 | assert.Nil(t, err) 66 | expected := &Tweet{ID: 581980947630845953, Text: "very unhappy tweet"} 67 | assert.Equal(t, expected, tweet) 68 | } 69 | -------------------------------------------------------------------------------- /twitter/followers.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/dghubble/sling" 7 | ) 8 | 9 | // FollowerIDs is a cursored collection of follower ids. 10 | type FollowerIDs struct { 11 | IDs []int64 `json:"ids"` 12 | NextCursor int64 `json:"next_cursor"` 13 | NextCursorStr string `json:"next_cursor_str"` 14 | PreviousCursor int64 `json:"previous_cursor"` 15 | PreviousCursorStr string `json:"previous_cursor_str"` 16 | } 17 | 18 | // Followers is a cursored collection of followers. 19 | type Followers struct { 20 | Users []User `json:"users"` 21 | NextCursor int64 `json:"next_cursor"` 22 | NextCursorStr string `json:"next_cursor_str"` 23 | PreviousCursor int64 `json:"previous_cursor"` 24 | PreviousCursorStr string `json:"previous_cursor_str"` 25 | } 26 | 27 | // FollowerService provides methods for accessing Twitter followers endpoints. 28 | type FollowerService struct { 29 | sling *sling.Sling 30 | } 31 | 32 | // newFollowerService returns a new FollowerService. 33 | func newFollowerService(sling *sling.Sling) *FollowerService { 34 | return &FollowerService{ 35 | sling: sling.Path("followers/"), 36 | } 37 | } 38 | 39 | // FollowerIDParams are the parameters for FollowerService.Ids 40 | type FollowerIDParams struct { 41 | UserID int64 `url:"user_id,omitempty"` 42 | ScreenName string `url:"screen_name,omitempty"` 43 | Cursor int64 `url:"cursor,omitempty"` 44 | Count int `url:"count,omitempty"` 45 | } 46 | 47 | // IDs returns a cursored collection of user ids following the specified user. 48 | // https://dev.twitter.com/rest/reference/get/followers/ids 49 | func (s *FollowerService) IDs(params *FollowerIDParams) (*FollowerIDs, *http.Response, error) { 50 | ids := new(FollowerIDs) 51 | apiError := new(APIError) 52 | resp, err := s.sling.New().Get("ids.json").QueryStruct(params).Receive(ids, apiError) 53 | return ids, resp, relevantError(err, *apiError) 54 | } 55 | 56 | // FollowerListParams are the parameters for FollowerService.List 57 | type FollowerListParams struct { 58 | UserID int64 `url:"user_id,omitempty"` 59 | ScreenName string `url:"screen_name,omitempty"` 60 | Cursor int64 `url:"cursor,omitempty"` 61 | Count int `url:"count,omitempty"` 62 | SkipStatus *bool `url:"skip_status,omitempty"` 63 | IncludeUserEntities *bool `url:"include_user_entities,omitempty"` 64 | } 65 | 66 | // List returns a cursored collection of Users following the specified user. 67 | // https://dev.twitter.com/rest/reference/get/followers/list 68 | func (s *FollowerService) List(params *FollowerListParams) (*Followers, *http.Response, error) { 69 | followers := new(Followers) 70 | apiError := new(APIError) 71 | resp, err := s.sling.New().Get("list.json").QueryStruct(params).Receive(followers, apiError) 72 | return followers, resp, relevantError(err, *apiError) 73 | } 74 | -------------------------------------------------------------------------------- /twitter/followers_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestFollowerService_Ids(t *testing.T) { 12 | httpClient, mux, server := testServer() 13 | defer server.Close() 14 | 15 | mux.HandleFunc("/1.1/followers/ids.json", func(w http.ResponseWriter, r *http.Request) { 16 | assertMethod(t, "GET", r) 17 | assertQuery(t, map[string]string{"user_id": "623265148", "count": "5", "cursor": "1516933260114270762"}, r) 18 | w.Header().Set("Content-Type", "application/json") 19 | fmt.Fprintf(w, `{"ids":[178082406,3318241001,1318020818,191714329,376703838],"next_cursor":1516837838944119498,"next_cursor_str":"1516837838944119498","previous_cursor":-1516924983503961435,"previous_cursor_str":"-1516924983503961435"}`) 20 | }) 21 | expected := &FollowerIDs{ 22 | IDs: []int64{178082406, 3318241001, 1318020818, 191714329, 376703838}, 23 | NextCursor: 1516837838944119498, 24 | NextCursorStr: "1516837838944119498", 25 | PreviousCursor: -1516924983503961435, 26 | PreviousCursorStr: "-1516924983503961435", 27 | } 28 | 29 | client := NewClient(httpClient) 30 | params := &FollowerIDParams{ 31 | UserID: 623265148, 32 | Count: 5, 33 | Cursor: 1516933260114270762, 34 | } 35 | followerIDs, _, err := client.Followers.IDs(params) 36 | assert.Nil(t, err) 37 | assert.Equal(t, expected, followerIDs) 38 | } 39 | 40 | func TestFollowerService_List(t *testing.T) { 41 | httpClient, mux, server := testServer() 42 | defer server.Close() 43 | 44 | mux.HandleFunc("/1.1/followers/list.json", func(w http.ResponseWriter, r *http.Request) { 45 | assertMethod(t, "GET", r) 46 | assertQuery(t, map[string]string{"screen_name": "dghubble", "count": "5", "cursor": "1516933260114270762", "skip_status": "true", "include_user_entities": "false"}, r) 47 | w.Header().Set("Content-Type", "application/json") 48 | fmt.Fprintf(w, `{"users": [{"id": 123}], "next_cursor":1516837838944119498,"next_cursor_str":"1516837838944119498","previous_cursor":-1516924983503961435,"previous_cursor_str":"-1516924983503961435"}`) 49 | }) 50 | expected := &Followers{ 51 | Users: []User{ 52 | {ID: 123}, 53 | }, 54 | NextCursor: 1516837838944119498, 55 | NextCursorStr: "1516837838944119498", 56 | PreviousCursor: -1516924983503961435, 57 | PreviousCursorStr: "-1516924983503961435", 58 | } 59 | 60 | client := NewClient(httpClient) 61 | params := &FollowerListParams{ 62 | ScreenName: "dghubble", 63 | Count: 5, 64 | Cursor: 1516933260114270762, 65 | SkipStatus: Bool(true), 66 | IncludeUserEntities: Bool(false), 67 | } 68 | followers, _, err := client.Followers.List(params) 69 | assert.Nil(t, err) 70 | assert.Equal(t, expected, followers) 71 | } 72 | -------------------------------------------------------------------------------- /twitter/friends.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/dghubble/sling" 7 | ) 8 | 9 | // FriendIDs is a cursored collection of friend ids. 10 | type FriendIDs struct { 11 | IDs []int64 `json:"ids"` 12 | NextCursor int64 `json:"next_cursor"` 13 | NextCursorStr string `json:"next_cursor_str"` 14 | PreviousCursor int64 `json:"previous_cursor"` 15 | PreviousCursorStr string `json:"previous_cursor_str"` 16 | } 17 | 18 | // Friends is a cursored collection of friends. 19 | type Friends struct { 20 | Users []User `json:"users"` 21 | NextCursor int64 `json:"next_cursor"` 22 | NextCursorStr string `json:"next_cursor_str"` 23 | PreviousCursor int64 `json:"previous_cursor"` 24 | PreviousCursorStr string `json:"previous_cursor_str"` 25 | } 26 | 27 | // FriendService provides methods for accessing Twitter friends endpoints. 28 | type FriendService struct { 29 | sling *sling.Sling 30 | } 31 | 32 | // newFriendService returns a new FriendService. 33 | func newFriendService(sling *sling.Sling) *FriendService { 34 | return &FriendService{ 35 | sling: sling.Path("friends/"), 36 | } 37 | } 38 | 39 | // FriendIDParams are the parameters for FriendService.Ids 40 | type FriendIDParams struct { 41 | UserID int64 `url:"user_id,omitempty"` 42 | ScreenName string `url:"screen_name,omitempty"` 43 | Cursor int64 `url:"cursor,omitempty"` 44 | Count int `url:"count,omitempty"` 45 | } 46 | 47 | // IDs returns a cursored collection of user ids that the specified user is following. 48 | // https://dev.twitter.com/rest/reference/get/friends/ids 49 | func (s *FriendService) IDs(params *FriendIDParams) (*FriendIDs, *http.Response, error) { 50 | ids := new(FriendIDs) 51 | apiError := new(APIError) 52 | resp, err := s.sling.New().Get("ids.json").QueryStruct(params).Receive(ids, apiError) 53 | return ids, resp, relevantError(err, *apiError) 54 | } 55 | 56 | // FriendListParams are the parameters for FriendService.List 57 | type FriendListParams struct { 58 | UserID int64 `url:"user_id,omitempty"` 59 | ScreenName string `url:"screen_name,omitempty"` 60 | Cursor int64 `url:"cursor,omitempty"` 61 | Count int `url:"count,omitempty"` 62 | SkipStatus *bool `url:"skip_status,omitempty"` 63 | IncludeUserEntities *bool `url:"include_user_entities,omitempty"` 64 | } 65 | 66 | // List returns a cursored collection of Users that the specified user is following. 67 | // https://dev.twitter.com/rest/reference/get/friends/list 68 | func (s *FriendService) List(params *FriendListParams) (*Friends, *http.Response, error) { 69 | friends := new(Friends) 70 | apiError := new(APIError) 71 | resp, err := s.sling.New().Get("list.json").QueryStruct(params).Receive(friends, apiError) 72 | return friends, resp, relevantError(err, *apiError) 73 | } 74 | -------------------------------------------------------------------------------- /twitter/friends_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestFriendService_Ids(t *testing.T) { 12 | httpClient, mux, server := testServer() 13 | defer server.Close() 14 | 15 | mux.HandleFunc("/1.1/friends/ids.json", func(w http.ResponseWriter, r *http.Request) { 16 | assertMethod(t, "GET", r) 17 | assertQuery(t, map[string]string{"user_id": "623265148", "count": "5", "cursor": "1516933260114270762"}, r) 18 | w.Header().Set("Content-Type", "application/json") 19 | fmt.Fprintf(w, `{"ids":[178082406,3318241001,1318020818,191714329,376703838],"next_cursor":1516837838944119498,"next_cursor_str":"1516837838944119498","previous_cursor":-1516924983503961435,"previous_cursor_str":"-1516924983503961435"}`) 20 | }) 21 | expected := &FriendIDs{ 22 | IDs: []int64{178082406, 3318241001, 1318020818, 191714329, 376703838}, 23 | NextCursor: 1516837838944119498, 24 | NextCursorStr: "1516837838944119498", 25 | PreviousCursor: -1516924983503961435, 26 | PreviousCursorStr: "-1516924983503961435", 27 | } 28 | 29 | client := NewClient(httpClient) 30 | params := &FriendIDParams{ 31 | UserID: 623265148, 32 | Count: 5, 33 | Cursor: 1516933260114270762, 34 | } 35 | friendIDs, _, err := client.Friends.IDs(params) 36 | assert.Nil(t, err) 37 | assert.Equal(t, expected, friendIDs) 38 | } 39 | 40 | func TestFriendService_List(t *testing.T) { 41 | httpClient, mux, server := testServer() 42 | defer server.Close() 43 | 44 | mux.HandleFunc("/1.1/friends/list.json", func(w http.ResponseWriter, r *http.Request) { 45 | assertMethod(t, "GET", r) 46 | assertQuery(t, map[string]string{"screen_name": "dghubble", "count": "5", "cursor": "1516933260114270762", "skip_status": "true", "include_user_entities": "false"}, r) 47 | w.Header().Set("Content-Type", "application/json") 48 | fmt.Fprintf(w, `{"users": [{"id": 123}], "next_cursor":1516837838944119498,"next_cursor_str":"1516837838944119498","previous_cursor":-1516924983503961435,"previous_cursor_str":"-1516924983503961435"}`) 49 | }) 50 | expected := &Friends{ 51 | Users: []User{ 52 | {ID: 123}, 53 | }, 54 | NextCursor: 1516837838944119498, 55 | NextCursorStr: "1516837838944119498", 56 | PreviousCursor: -1516924983503961435, 57 | PreviousCursorStr: "-1516924983503961435", 58 | } 59 | 60 | client := NewClient(httpClient) 61 | params := &FriendListParams{ 62 | ScreenName: "dghubble", 63 | Count: 5, 64 | Cursor: 1516933260114270762, 65 | SkipStatus: Bool(true), 66 | IncludeUserEntities: Bool(false), 67 | } 68 | friends, _, err := client.Friends.List(params) 69 | assert.Nil(t, err) 70 | assert.Equal(t, expected, friends) 71 | } 72 | -------------------------------------------------------------------------------- /twitter/friendships.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/dghubble/sling" 7 | ) 8 | 9 | // FriendshipService provides methods for accessing Twitter friendship API 10 | // endpoints. 11 | type FriendshipService struct { 12 | sling *sling.Sling 13 | } 14 | 15 | // newFriendshipService returns a new FriendshipService. 16 | func newFriendshipService(sling *sling.Sling) *FriendshipService { 17 | return &FriendshipService{ 18 | sling: sling.Path("friendships/"), 19 | } 20 | } 21 | 22 | // FriendshipCreateParams are parameters for FriendshipService.Create 23 | type FriendshipCreateParams struct { 24 | ScreenName string `url:"screen_name,omitempty"` 25 | UserID int64 `url:"user_id,omitempty"` 26 | Follow *bool `url:"follow,omitempty"` 27 | } 28 | 29 | // Create creates a friendship to (i.e. follows) the specified user and 30 | // returns the followed user. 31 | // Requires a user auth context. 32 | // https://dev.twitter.com/rest/reference/post/friendships/create 33 | func (s *FriendshipService) Create(params *FriendshipCreateParams) (*User, *http.Response, error) { 34 | user := new(User) 35 | apiError := new(APIError) 36 | resp, err := s.sling.New().Post("create.json").QueryStruct(params).Receive(user, apiError) 37 | return user, resp, relevantError(err, *apiError) 38 | } 39 | 40 | // FriendshipShowParams are paramenters for FriendshipService.Show 41 | type FriendshipShowParams struct { 42 | SourceID int64 `url:"source_id,omitempty"` 43 | SourceScreenName string `url:"source_screen_name,omitempty"` 44 | TargetID int64 `url:"target_id,omitempty"` 45 | TargetScreenName string `url:"target_screen_name,omitempty"` 46 | } 47 | 48 | // Show returns the relationship between two arbitrary users. 49 | // Requires a user auth or an app context. 50 | // https://dev.twitter.com/rest/reference/get/friendships/show 51 | func (s *FriendshipService) Show(params *FriendshipShowParams) (*Relationship, *http.Response, error) { 52 | response := new(RelationshipResponse) 53 | apiError := new(APIError) 54 | resp, err := s.sling.New().Get("show.json").QueryStruct(params).Receive(response, apiError) 55 | return response.Relationship, resp, relevantError(err, *apiError) 56 | } 57 | 58 | // RelationshipResponse contains a relationship. 59 | type RelationshipResponse struct { 60 | Relationship *Relationship `json:"relationship"` 61 | } 62 | 63 | // Relationship represents the relation between a source user and target user. 64 | type Relationship struct { 65 | Source RelationshipSource `json:"source"` 66 | Target RelationshipTarget `json:"target"` 67 | } 68 | 69 | // RelationshipSource represents the source user. 70 | type RelationshipSource struct { 71 | ID int64 `json:"id"` 72 | IDStr string `json:"id_str"` 73 | ScreenName string `json:"screen_name"` 74 | Following bool `json:"following"` 75 | FollowedBy bool `json:"followed_by"` 76 | CanDM bool `json:"can_dm"` 77 | Blocking bool `json:"blocking"` 78 | Muting bool `json:"muting"` 79 | AllReplies bool `json:"all_replies"` 80 | WantRetweets bool `json:"want_retweets"` 81 | MarkedSpam bool `json:"marked_spam"` 82 | NotificationsEnabled bool `json:"notifications_enabled"` 83 | } 84 | 85 | // RelationshipTarget represents the target user. 86 | type RelationshipTarget struct { 87 | ID int64 `json:"id"` 88 | IDStr string `json:"id_str"` 89 | ScreenName string `json:"screen_name"` 90 | Following bool `json:"following"` 91 | FollowedBy bool `json:"followed_by"` 92 | } 93 | 94 | // FriendshipDestroyParams are paramenters for FriendshipService.Destroy 95 | type FriendshipDestroyParams struct { 96 | ScreenName string `url:"screen_name,omitempty"` 97 | UserID int64 `url:"user_id,omitempty"` 98 | } 99 | 100 | // Destroy destroys a friendship to (i.e. unfollows) the specified user and 101 | // returns the unfollowed user. 102 | // Requires a user auth context. 103 | // https://dev.twitter.com/rest/reference/post/friendships/destroy 104 | func (s *FriendshipService) Destroy(params *FriendshipDestroyParams) (*User, *http.Response, error) { 105 | user := new(User) 106 | apiError := new(APIError) 107 | resp, err := s.sling.New().Post("destroy.json").QueryStruct(params).Receive(user, apiError) 108 | return user, resp, relevantError(err, *apiError) 109 | } 110 | 111 | // FriendshipPendingParams are paramenters for FriendshipService.Outgoing 112 | type FriendshipPendingParams struct { 113 | Cursor int64 `url:"cursor,omitempty"` 114 | } 115 | 116 | // Outgoing returns a collection of numeric IDs for every protected user for whom the authenticating 117 | // user has a pending follow request. 118 | // https://dev.twitter.com/rest/reference/get/friendships/outgoing 119 | func (s *FriendshipService) Outgoing(params *FriendshipPendingParams) (*FriendIDs, *http.Response, error) { 120 | ids := new(FriendIDs) 121 | apiError := new(APIError) 122 | resp, err := s.sling.New().Get("outgoing.json").QueryStruct(params).Receive(ids, apiError) 123 | return ids, resp, relevantError(err, *apiError) 124 | } 125 | 126 | // Incoming returns a collection of numeric IDs for every user who has a pending request to 127 | // follow the authenticating user. 128 | // https://dev.twitter.com/rest/reference/get/friendships/incoming 129 | func (s *FriendshipService) Incoming(params *FriendshipPendingParams) (*FriendIDs, *http.Response, error) { 130 | ids := new(FriendIDs) 131 | apiError := new(APIError) 132 | resp, err := s.sling.New().Get("incoming.json").QueryStruct(params).Receive(ids, apiError) 133 | return ids, resp, relevantError(err, *apiError) 134 | } 135 | 136 | // FriendshipLookupParams are parameters for FriendshipService.Lookup 137 | type FriendshipLookupParams struct { 138 | UserID []int64 `url:"user_id,omitempty,comma"` 139 | ScreenName []string `url:"screen_name,omitempty,comma"` 140 | } 141 | 142 | // FriendshipResponse represents the target user. 143 | type FriendshipResponse struct { 144 | ID int64 `json:"id"` 145 | IDStr string `json:"id_str"` 146 | ScreenName string `json:"screen_name"` 147 | Name string `json:"name"` 148 | Connections []string `json:"connections"` 149 | } 150 | 151 | // Lookup returns the relationships of the authenticating user to the comma-separated list of up to 152 | // 100 screen_names or user_ids provided. 153 | // https://dev.twitter.com/rest/reference/get/friendships/lookup 154 | func (s *FriendshipService) Lookup(params *FriendshipLookupParams) (*[]FriendshipResponse, *http.Response, error) { 155 | ids := new([]FriendshipResponse) 156 | apiError := new(APIError) 157 | resp, err := s.sling.New().Get("lookup.json").QueryStruct(params).Receive(ids, apiError) 158 | return ids, resp, relevantError(err, *apiError) 159 | } 160 | -------------------------------------------------------------------------------- /twitter/friendships_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestFriendshipService_Create(t *testing.T) { 12 | httpClient, mux, server := testServer() 13 | defer server.Close() 14 | 15 | mux.HandleFunc("/1.1/friendships/create.json", func(w http.ResponseWriter, r *http.Request) { 16 | assertMethod(t, "POST", r) 17 | assertPostForm(t, map[string]string{"user_id": "12345"}, r) 18 | w.Header().Set("Content-Type", "application/json") 19 | fmt.Fprintf(w, `{"id": 12345, "name": "Doug Williams"}`) 20 | }) 21 | 22 | client := NewClient(httpClient) 23 | params := &FriendshipCreateParams{UserID: 12345} 24 | user, _, err := client.Friendships.Create(params) 25 | assert.Nil(t, err) 26 | expected := &User{ID: 12345, Name: "Doug Williams"} 27 | assert.Equal(t, expected, user) 28 | } 29 | 30 | func TestFriendshipService_Show(t *testing.T) { 31 | httpClient, mux, server := testServer() 32 | defer server.Close() 33 | 34 | mux.HandleFunc("/1.1/friendships/show.json", func(w http.ResponseWriter, r *http.Request) { 35 | assertMethod(t, "GET", r) 36 | assertQuery(t, map[string]string{"source_screen_name": "foo", "target_screen_name": "bar"}, r) 37 | w.Header().Set("Content-Type", "application/json") 38 | fmt.Fprintf(w, `{ "relationship": { "source": { "can_dm": false, "muting": true, "id_str": "8649302", "id": 8649302, "screen_name": "foo"}, "target": { "id_str": "12148", "id": 12148, "screen_name": "bar", "following": true, "followed_by": false } } }`) 39 | }) 40 | 41 | client := NewClient(httpClient) 42 | params := &FriendshipShowParams{SourceScreenName: "foo", TargetScreenName: "bar"} 43 | relationship, _, err := client.Friendships.Show(params) 44 | assert.Nil(t, err) 45 | expected := &Relationship{ 46 | Source: RelationshipSource{ID: 8649302, ScreenName: "foo", IDStr: "8649302", CanDM: false, Muting: true, WantRetweets: false}, 47 | Target: RelationshipTarget{ID: 12148, ScreenName: "bar", IDStr: "12148", Following: true, FollowedBy: false}, 48 | } 49 | assert.Equal(t, expected, relationship) 50 | } 51 | 52 | func TestFriendshipService_Destroy(t *testing.T) { 53 | httpClient, mux, server := testServer() 54 | defer server.Close() 55 | 56 | mux.HandleFunc("/1.1/friendships/destroy.json", func(w http.ResponseWriter, r *http.Request) { 57 | assertMethod(t, "POST", r) 58 | assertPostForm(t, map[string]string{"user_id": "12345"}, r) 59 | w.Header().Set("Content-Type", "application/json") 60 | fmt.Fprintf(w, `{"id": 12345, "name": "Doug Williams"}`) 61 | }) 62 | 63 | client := NewClient(httpClient) 64 | params := &FriendshipDestroyParams{UserID: 12345} 65 | user, _, err := client.Friendships.Destroy(params) 66 | assert.Nil(t, err) 67 | expected := &User{ID: 12345, Name: "Doug Williams"} 68 | assert.Equal(t, expected, user) 69 | } 70 | 71 | func TestFriendshipService_Outgoing(t *testing.T) { 72 | httpClient, mux, server := testServer() 73 | defer server.Close() 74 | 75 | mux.HandleFunc("/1.1/friendships/outgoing.json", func(w http.ResponseWriter, r *http.Request) { 76 | assertMethod(t, "GET", r) 77 | assertQuery(t, map[string]string{"cursor": "1516933260114270762"}, r) 78 | w.Header().Set("Content-Type", "application/json") 79 | fmt.Fprintf(w, `{"ids":[178082406,3318241001,1318020818,191714329,376703838],"next_cursor":1516837838944119498,"next_cursor_str":"1516837838944119498","previous_cursor":-1516924983503961435,"previous_cursor_str":"-1516924983503961435"}`) 80 | }) 81 | expected := &FriendIDs{ 82 | IDs: []int64{178082406, 3318241001, 1318020818, 191714329, 376703838}, 83 | NextCursor: 1516837838944119498, 84 | NextCursorStr: "1516837838944119498", 85 | PreviousCursor: -1516924983503961435, 86 | PreviousCursorStr: "-1516924983503961435", 87 | } 88 | 89 | client := NewClient(httpClient) 90 | params := &FriendshipPendingParams{ 91 | Cursor: 1516933260114270762, 92 | } 93 | friendIDs, _, err := client.Friendships.Outgoing(params) 94 | assert.Nil(t, err) 95 | assert.Equal(t, expected, friendIDs) 96 | } 97 | 98 | func TestFriendshipService_Incoming(t *testing.T) { 99 | httpClient, mux, server := testServer() 100 | defer server.Close() 101 | 102 | mux.HandleFunc("/1.1/friendships/incoming.json", func(w http.ResponseWriter, r *http.Request) { 103 | assertMethod(t, "GET", r) 104 | assertQuery(t, map[string]string{"cursor": "1516933260114270762"}, r) 105 | w.Header().Set("Content-Type", "application/json") 106 | fmt.Fprintf(w, `{"ids":[178082406,3318241001,1318020818,191714329,376703838],"next_cursor":1516837838944119498,"next_cursor_str":"1516837838944119498","previous_cursor":-1516924983503961435,"previous_cursor_str":"-1516924983503961435"}`) 107 | }) 108 | expected := &FriendIDs{ 109 | IDs: []int64{178082406, 3318241001, 1318020818, 191714329, 376703838}, 110 | NextCursor: 1516837838944119498, 111 | NextCursorStr: "1516837838944119498", 112 | PreviousCursor: -1516924983503961435, 113 | PreviousCursorStr: "-1516924983503961435", 114 | } 115 | 116 | client := NewClient(httpClient) 117 | params := &FriendshipPendingParams{ 118 | Cursor: 1516933260114270762, 119 | } 120 | friendIDs, _, err := client.Friendships.Incoming(params) 121 | assert.Nil(t, err) 122 | assert.Equal(t, expected, friendIDs) 123 | } 124 | 125 | func TestFriendshipService_Lookup(t *testing.T) { 126 | httpClient, mux, server := testServer() 127 | defer server.Close() 128 | 129 | mux.HandleFunc("/1.1/friendships/lookup.json", func(w http.ResponseWriter, r *http.Request) { 130 | assertMethod(t, "GET", r) 131 | assertQuery(t, map[string]string{"user_id": "123,214"}, r) 132 | w.Header().Set("Content-Type", "application/json") 133 | fmt.Fprintf(w, `[{"name": "andy piper (pipes)","screen_name": "andypiper","id": 786491,"id_str": "786491","connections": ["following"]}]`) 134 | }) 135 | 136 | expected := &[]FriendshipResponse{ 137 | {Name: "andy piper (pipes)", ScreenName: "andypiper", ID: 786491, IDStr: "786491", Connections: []string{"following"}}, 138 | } 139 | 140 | client := NewClient(httpClient) 141 | params := &FriendshipLookupParams{ 142 | UserID: []int64{123, 214}, 143 | } 144 | friendIDs, _, err := client.Friendships.Lookup(params) 145 | assert.Nil(t, err) 146 | assert.Equal(t, expected, friendIDs) 147 | } 148 | -------------------------------------------------------------------------------- /twitter/premium_search.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/dghubble/sling" 8 | ) 9 | 10 | // PremiumSearch represents the result of a Tweet search. 11 | // https://developer.twitter.com/en/docs/tweets/search/api-reference/premium-search 12 | type PremiumSearch struct { 13 | Results []Tweet `json:"results"` 14 | Next string `json:"next"` 15 | RequestParameters *RequestParameters `json:"requestParameters"` 16 | } 17 | 18 | // RequestParameters describes a request parameter that was passed to a Premium search API. 19 | type RequestParameters struct { 20 | MaxResults int `json:"maxResults"` 21 | FromDate string `json:"fromDate"` 22 | ToDate string `json:"toDate"` 23 | } 24 | 25 | // PremiumSearchCount describes a response of Premium search API's count endpoint. 26 | // https://developer.twitter.com/en/docs/tweets/search/api-reference/premium-search#CountsEndpoint 27 | type PremiumSearchCount struct { 28 | Results []TweetCount `json:"results"` 29 | TotalCount int64 `json:"totalCount"` 30 | RequestParameters *RequestCountParameters `json:"requestParameters"` 31 | } 32 | 33 | // RequestCountParameters describes a request parameter that was passed to a Premium search API. 34 | type RequestCountParameters struct { 35 | Bucket string `json:"bucket"` 36 | FromDate string `json:"fromDate"` 37 | ToDate string `json:"toDate"` 38 | } 39 | 40 | // TweetCount represents a count of Tweets in the TimePeriod matching a search query. 41 | type TweetCount struct { 42 | TimePeriod string `json:"timePeriod"` 43 | Count int64 `json:"count"` 44 | } 45 | 46 | // PremiumSearchService provides methods for accessing Twitter premium search API endpoints. 47 | type PremiumSearchService struct { 48 | sling *sling.Sling 49 | } 50 | 51 | // newSearchService returns a new SearchService. 52 | func newPremiumSearchService(sling *sling.Sling) *PremiumSearchService { 53 | return &PremiumSearchService{ 54 | sling: sling.Path("tweets/search/"), 55 | } 56 | } 57 | 58 | // PremiumSearchTweetParams are the parameters for PremiumSearchService.SearchFullArchive and Search30Days 59 | type PremiumSearchTweetParams struct { 60 | Query string `url:"query,omitempty"` 61 | Tag string `url:"tag,omitempty"` 62 | FromDate string `url:"fromDate,omitempty"` 63 | ToDate string `url:"toDate,omitempty"` 64 | MaxResults int `url:"maxResults,omitempty"` 65 | Next string `url:"next,omitempty"` 66 | } 67 | 68 | // PremiumSearchCountTweetParams are the parameters for PremiumSearchService.CountFullArchive and Count30Days 69 | type PremiumSearchCountTweetParams struct { 70 | Query string `url:"query,omitempty"` 71 | Tag string `url:"tag,omitempty"` 72 | FromDate string `url:"fromDate,omitempty"` 73 | ToDate string `url:"toDate,omitempty"` 74 | Bucket string `url:"bucket,omitempty"` 75 | Next string `url:"next,omitempty"` 76 | } 77 | 78 | // SearchFullArchive returns a collection of Tweets matching a search query from tweets back to the very first tweet. 79 | // https://developer.twitter.com/en/docs/tweets/search/api-reference/premium-search 80 | func (s *PremiumSearchService) SearchFullArchive(params *PremiumSearchTweetParams, label string) (*PremiumSearch, *http.Response, error) { 81 | search := new(PremiumSearch) 82 | apiError := new(APIError) 83 | path := fmt.Sprintf("fullarchive/%s.json", label) 84 | resp, err := s.sling.New().Get(path).QueryStruct(params).Receive(search, apiError) 85 | return search, resp, relevantError(err, *apiError) 86 | } 87 | 88 | // Search30Days returns a collection of Tweets matching a search query from Tweets posted within the last 30 days. 89 | // https://developer.twitter.com/en/docs/tweets/search/api-reference/premium-search 90 | func (s *PremiumSearchService) Search30Days(params *PremiumSearchTweetParams, label string) (*PremiumSearch, *http.Response, error) { 91 | search := new(PremiumSearch) 92 | apiError := new(APIError) 93 | path := fmt.Sprintf("30day/%s.json", label) 94 | resp, err := s.sling.New().Get(path).QueryStruct(params).Receive(search, apiError) 95 | return search, resp, relevantError(err, *apiError) 96 | } 97 | 98 | // CountFullArchive returns a counts of Tweets matching a search query from tweets back to the very first tweet. 99 | // https://developer.twitter.com/en/docs/tweets/search/api-reference/premium-search#CountsEndpoint 100 | func (s *PremiumSearchService) CountFullArchive(params *PremiumSearchCountTweetParams, label string) (*PremiumSearchCount, *http.Response, error) { 101 | counts := new(PremiumSearchCount) 102 | apiError := new(APIError) 103 | path := fmt.Sprintf("fullarchive/%s/counts.json", label) 104 | resp, err := s.sling.New().Get(path).QueryStruct(params).Receive(counts, apiError) 105 | return counts, resp, relevantError(err, *apiError) 106 | } 107 | 108 | // Count30Days returns a counts of Tweets matching a search query from Tweets posted within the last 30 days. 109 | // https://developer.twitter.com/en/docs/tweets/search/api-reference/premium-search#CountsEndpoint 110 | func (s *PremiumSearchService) Count30Days(params *PremiumSearchCountTweetParams, label string) (*PremiumSearchCount, *http.Response, error) { 111 | counts := new(PremiumSearchCount) 112 | apiError := new(APIError) 113 | path := fmt.Sprintf("30day/%s/counts.json", label) 114 | resp, err := s.sling.New().Get(path).QueryStruct(params).Receive(counts, apiError) 115 | return counts, resp, relevantError(err, *apiError) 116 | } 117 | -------------------------------------------------------------------------------- /twitter/premium_search_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestPremiumSearchService_Tweets(t *testing.T) { 12 | httpClient, mux, server := testServer() 13 | defer server.Close() 14 | 15 | assertSearchBody := func(t *testing.T, r *http.Request) { 16 | assertMethod(t, "GET", r) 17 | assertQuery(t, map[string]string{"query": "url:\"http://example.com\"", "tag": "8HYG54ZGTU", "fromDate": "201512220000", "toDate": "201712220000", "maxResults": "500", "next": "NTcxODIyMDMyODMwMjU1MTA0"}, r) 18 | } 19 | setResponse := func(w http.ResponseWriter) { 20 | w.Header().Set("Content-Type", "application/json") 21 | fmt.Fprintf(w, `{"results":[{"id":781760642139250689}],"next":"NTcxODIyMDMyODMwMjU1MTA0","requestParameters":{"maxResults":500,"fromDate":"201512200000","toDate":"201712200000"}}`) 22 | } 23 | 24 | mux.HandleFunc("/1.1/tweets/search/fullarchive/test.json", func(w http.ResponseWriter, r *http.Request) { 25 | assertSearchBody(t, r) 26 | setResponse(w) 27 | }) 28 | mux.HandleFunc("/1.1/tweets/search/30day/test.json", func(w http.ResponseWriter, r *http.Request) { 29 | assertSearchBody(t, r) 30 | setResponse(w) 31 | }) 32 | 33 | params := &PremiumSearchTweetParams{ 34 | Query: "url:\"http://example.com\"", 35 | Tag: "8HYG54ZGTU", 36 | FromDate: "201512220000", 37 | ToDate: "201712220000", 38 | MaxResults: 500, 39 | Next: "NTcxODIyMDMyODMwMjU1MTA0", 40 | } 41 | expected := &PremiumSearch{ 42 | Results: []Tweet{ 43 | {ID: 781760642139250689}, 44 | }, 45 | Next: "NTcxODIyMDMyODMwMjU1MTA0", 46 | RequestParameters: &RequestParameters{ 47 | MaxResults: 500, 48 | FromDate: "201512200000", 49 | ToDate: "201712200000", 50 | }, 51 | } 52 | 53 | { 54 | client := NewClient(httpClient) 55 | search, _, err := client.PremiumSearch.SearchFullArchive( 56 | params, 57 | "test", 58 | ) 59 | assert.Nil(t, err) 60 | assert.Equal(t, expected, search) 61 | } 62 | { 63 | client := NewClient(httpClient) 64 | search, _, err := client.PremiumSearch.Search30Days( 65 | params, 66 | "test", 67 | ) 68 | assert.Nil(t, err) 69 | assert.Equal(t, expected, search) 70 | } 71 | } 72 | 73 | func TestPremiumSearchService_Counts(t *testing.T) { 74 | httpClient, mux, server := testServer() 75 | defer server.Close() 76 | 77 | assertCountBody := func(t *testing.T, r *http.Request) { 78 | assertMethod(t, "GET", r) 79 | assertQuery(t, map[string]string{"query": "url:\"http://example.com\"", "tag": "8HYG54ZGTU", "fromDate": "201512220000", "toDate": "201712220000", "bucket": "day", "next": "NTcxODIyMDMyODMwMjU1MTA0"}, r) 80 | } 81 | setResponse := func(w http.ResponseWriter) { 82 | w.Header().Set("Content-Type", "application/json") 83 | fmt.Fprintf(w, `{"results":[{"timePeriod":"201701010000","count":32},{"timePeriod":"201701020000","count":45}],"totalCount":2027,"requestParameters":{"bucket":"day","fromDate":"201512200000","toDate":"201712200000"}}`) 84 | } 85 | 86 | mux.HandleFunc("/1.1/tweets/search/fullarchive/test/counts.json", func(w http.ResponseWriter, r *http.Request) { 87 | assertCountBody(t, r) 88 | setResponse(w) 89 | }) 90 | mux.HandleFunc("/1.1/tweets/search/30day/test/counts.json", func(w http.ResponseWriter, r *http.Request) { 91 | assertCountBody(t, r) 92 | setResponse(w) 93 | }) 94 | 95 | params := &PremiumSearchCountTweetParams{ 96 | Query: "url:\"http://example.com\"", 97 | Tag: "8HYG54ZGTU", 98 | FromDate: "201512220000", 99 | ToDate: "201712220000", 100 | Bucket: "day", 101 | Next: "NTcxODIyMDMyODMwMjU1MTA0", 102 | } 103 | expected := &PremiumSearchCount{ 104 | Results: []TweetCount{ 105 | { 106 | TimePeriod: "201701010000", 107 | Count: 32, 108 | }, 109 | { 110 | TimePeriod: "201701020000", 111 | Count: 45, 112 | }, 113 | }, 114 | TotalCount: 2027, 115 | RequestParameters: &RequestCountParameters{ 116 | Bucket: "day", 117 | FromDate: "201512200000", 118 | ToDate: "201712200000", 119 | }, 120 | } 121 | 122 | { 123 | client := NewClient(httpClient) 124 | search, _, err := client.PremiumSearch.CountFullArchive( 125 | params, 126 | "test", 127 | ) 128 | assert.Nil(t, err) 129 | assert.Equal(t, expected, search) 130 | } 131 | { 132 | client := NewClient(httpClient) 133 | search, _, err := client.PremiumSearch.Count30Days( 134 | params, 135 | "test", 136 | ) 137 | assert.Nil(t, err) 138 | assert.Equal(t, expected, search) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /twitter/rate_limits.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/dghubble/sling" 7 | ) 8 | 9 | // RateLimitService provides methods for accessing Twitter rate limits 10 | // API endpoints. 11 | type RateLimitService struct { 12 | sling *sling.Sling 13 | } 14 | 15 | // newRateLimitService returns a new RateLimitService. 16 | func newRateLimitService(sling *sling.Sling) *RateLimitService { 17 | return &RateLimitService{ 18 | sling: sling.Path("application/"), 19 | } 20 | } 21 | 22 | // RateLimit summarizes current rate limits of resource families. 23 | type RateLimit struct { 24 | RateLimitContext *RateLimitContext `json:"rate_limit_context"` 25 | Resources *RateLimitResources `json:"resources"` 26 | } 27 | 28 | // RateLimitContext contains auth context 29 | type RateLimitContext struct { 30 | AccessToken string `json:"access_token"` 31 | } 32 | 33 | // RateLimitResources contains all limit status data for endpoints group by resources 34 | type RateLimitResources struct { 35 | Application map[string]*RateLimitResource `json:"application"` 36 | Favorites map[string]*RateLimitResource `json:"favorites"` 37 | Followers map[string]*RateLimitResource `json:"followers"` 38 | Friends map[string]*RateLimitResource `json:"friends"` 39 | Friendships map[string]*RateLimitResource `json:"friendships"` 40 | Geo map[string]*RateLimitResource `json:"geo"` 41 | Help map[string]*RateLimitResource `json:"help"` 42 | Lists map[string]*RateLimitResource `json:"lists"` 43 | Search map[string]*RateLimitResource `json:"search"` 44 | Statuses map[string]*RateLimitResource `json:"statuses"` 45 | Trends map[string]*RateLimitResource `json:"trends"` 46 | Users map[string]*RateLimitResource `json:"users"` 47 | } 48 | 49 | // RateLimitResource contains limit status data for a single endpoint 50 | type RateLimitResource struct { 51 | Limit int `json:"limit"` 52 | Remaining int `json:"remaining"` 53 | Reset int `json:"reset"` 54 | } 55 | 56 | // RateLimitParams are the parameters for RateLimitService.Status. 57 | type RateLimitParams struct { 58 | Resources []string `url:"resources,omitempty,comma"` 59 | } 60 | 61 | // Status summarizes the current rate limits of specified resource families. 62 | // https://developer.twitter.com/en/docs/developer-utilities/rate-limit-status/api-reference/get-application-rate_limit_status 63 | func (s *RateLimitService) Status(params *RateLimitParams) (*RateLimit, *http.Response, error) { 64 | rateLimit := new(RateLimit) 65 | apiError := new(APIError) 66 | resp, err := s.sling.New().Get("rate_limit_status.json").QueryStruct(params).Receive(rateLimit, apiError) 67 | return rateLimit, resp, relevantError(err, *apiError) 68 | } 69 | -------------------------------------------------------------------------------- /twitter/rate_limits_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestRateLimitService_Status(t *testing.T) { 12 | httpClient, mux, server := testServer() 13 | defer server.Close() 14 | 15 | mux.HandleFunc("/1.1/application/rate_limit_status.json", func(w http.ResponseWriter, r *http.Request) { 16 | assertMethod(t, "GET", r) 17 | assertQuery(t, map[string]string{"resources": "statuses,users"}, r) 18 | w.Header().Set("Content-Type", "application/json") 19 | fmt.Fprintf(w, `{"rate_limit_context":{"access_token":"a_fake_access_token"},"resources":{"statuses":{"/statuses/mentions_timeline":{"limit":75,"remaining":75,"reset":1403602426},"/statuses/lookup":{"limit":900,"remaining":900,"reset":1403602426}}}}`) 20 | }) 21 | 22 | client := NewClient(httpClient) 23 | rateLimits, _, err := client.RateLimits.Status(&RateLimitParams{Resources: []string{"statuses", "users"}}) 24 | expected := &RateLimit{ 25 | RateLimitContext: &RateLimitContext{AccessToken: "a_fake_access_token"}, 26 | Resources: &RateLimitResources{ 27 | Statuses: map[string]*RateLimitResource{ 28 | "/statuses/mentions_timeline": { 29 | Limit: 75, 30 | Remaining: 75, 31 | Reset: 1403602426, 32 | }, 33 | "/statuses/lookup": { 34 | Limit: 900, 35 | Remaining: 900, 36 | Reset: 1403602426, 37 | }, 38 | }}} 39 | 40 | assert.Nil(t, err) 41 | assert.Equal(t, expected, rateLimits) 42 | } 43 | -------------------------------------------------------------------------------- /twitter/search.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/dghubble/sling" 7 | ) 8 | 9 | // Search represents the result of a Tweet search. 10 | type Search struct { 11 | Statuses []Tweet `json:"statuses"` 12 | Metadata *SearchMetadata `json:"search_metadata"` 13 | } 14 | 15 | // SearchMetadata describes a Search result. 16 | type SearchMetadata struct { 17 | Count int `json:"count"` 18 | SinceID int64 `json:"since_id"` 19 | SinceIDStr string `json:"since_id_str"` 20 | MaxID int64 `json:"max_id"` 21 | MaxIDStr string `json:"max_id_str"` 22 | RefreshURL string `json:"refresh_url"` 23 | NextResults string `json:"next_results"` 24 | CompletedIn float64 `json:"completed_in"` 25 | Query string `json:"query"` 26 | } 27 | 28 | // SearchService provides methods for accessing Twitter search API endpoints. 29 | type SearchService struct { 30 | sling *sling.Sling 31 | } 32 | 33 | // newSearchService returns a new SearchService. 34 | func newSearchService(sling *sling.Sling) *SearchService { 35 | return &SearchService{ 36 | sling: sling.Path("search/"), 37 | } 38 | } 39 | 40 | // SearchTweetParams are the parameters for SearchService.Tweets 41 | type SearchTweetParams struct { 42 | Query string `url:"q,omitempty"` 43 | Geocode string `url:"geocode,omitempty"` 44 | Lang string `url:"lang,omitempty"` 45 | Locale string `url:"locale,omitempty"` 46 | ResultType string `url:"result_type,omitempty"` 47 | Count int `url:"count,omitempty"` 48 | SinceID int64 `url:"since_id,omitempty"` 49 | MaxID int64 `url:"max_id,omitempty"` 50 | Until string `url:"until,omitempty"` 51 | Since string `url:"since,omitempty"` 52 | Filter string `url:"filter,omitempty"` 53 | IncludeEntities *bool `url:"include_entities,omitempty"` 54 | TweetMode string `url:"tweet_mode,omitempty"` 55 | } 56 | 57 | // Tweets returns a collection of Tweets matching a search query. 58 | // https://dev.twitter.com/rest/reference/get/search/tweets 59 | func (s *SearchService) Tweets(params *SearchTweetParams) (*Search, *http.Response, error) { 60 | search := new(Search) 61 | apiError := new(APIError) 62 | resp, err := s.sling.New().Get("tweets.json").QueryStruct(params).Receive(search, apiError) 63 | return search, resp, relevantError(err, *apiError) 64 | } 65 | -------------------------------------------------------------------------------- /twitter/search_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestSearchService_Tweets(t *testing.T) { 12 | httpClient, mux, server := testServer() 13 | defer server.Close() 14 | 15 | mux.HandleFunc("/1.1/search/tweets.json", func(w http.ResponseWriter, r *http.Request) { 16 | assertMethod(t, "GET", r) 17 | assertQuery(t, map[string]string{"q": "happy birthday", "result_type": "popular", "count": "1", "since": "2012-01-01", "filter": "safe"}, r) 18 | w.Header().Set("Content-Type", "application/json") 19 | fmt.Fprintf(w, `{"statuses":[{"id":781760642139250689}],"search_metadata":{"completed_in":0.043,"max_id":781760642139250689,"max_id_str":"781760642139250689","next_results":"?max_id=781760640104828927&q=happy+birthday&count=1&include_entities=1","query":"happy birthday","refresh_url":"?since_id=781760642139250689&q=happy+birthday&include_entities=1","count":1,"since_id":0,"since_id_str":"0", "since":"2012-01-01", "filter":"safe"}}`) 20 | }) 21 | 22 | client := NewClient(httpClient) 23 | search, _, err := client.Search.Tweets(&SearchTweetParams{ 24 | Query: "happy birthday", 25 | Count: 1, 26 | ResultType: "popular", 27 | Since: "2012-01-01", 28 | Filter: "safe"}) 29 | expected := &Search{ 30 | Statuses: []Tweet{ 31 | {ID: 781760642139250689}, 32 | }, 33 | Metadata: &SearchMetadata{ 34 | Count: 1, 35 | SinceID: 0, 36 | SinceIDStr: "0", 37 | MaxID: 781760642139250689, 38 | MaxIDStr: "781760642139250689", 39 | RefreshURL: "?since_id=781760642139250689&q=happy+birthday&include_entities=1", 40 | NextResults: "?max_id=781760640104828927&q=happy+birthday&count=1&include_entities=1", 41 | CompletedIn: 0.043, 42 | Query: "happy birthday", 43 | }, 44 | } 45 | assert.Nil(t, err) 46 | assert.Equal(t, expected, search) 47 | } 48 | -------------------------------------------------------------------------------- /twitter/statuses.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/dghubble/sling" 9 | ) 10 | 11 | // Tweet represents a Twitter Tweet, previously called a status. 12 | // https://dev.twitter.com/overview/api/tweets 13 | type Tweet struct { 14 | Coordinates *Coordinates `json:"coordinates"` 15 | CreatedAt string `json:"created_at"` 16 | CurrentUserRetweet *TweetIdentifier `json:"current_user_retweet"` 17 | Entities *Entities `json:"entities"` 18 | FavoriteCount int `json:"favorite_count"` 19 | Favorited bool `json:"favorited"` 20 | FilterLevel string `json:"filter_level"` 21 | ID int64 `json:"id"` 22 | IDStr string `json:"id_str"` 23 | InReplyToScreenName string `json:"in_reply_to_screen_name"` 24 | InReplyToStatusID int64 `json:"in_reply_to_status_id"` 25 | InReplyToStatusIDStr string `json:"in_reply_to_status_id_str"` 26 | InReplyToUserID int64 `json:"in_reply_to_user_id"` 27 | InReplyToUserIDStr string `json:"in_reply_to_user_id_str"` 28 | Lang string `json:"lang"` 29 | PossiblySensitive bool `json:"possibly_sensitive"` 30 | QuoteCount int `json:"quote_count"` 31 | ReplyCount int `json:"reply_count"` 32 | RetweetCount int `json:"retweet_count"` 33 | Retweeted bool `json:"retweeted"` 34 | RetweetedStatus *Tweet `json:"retweeted_status"` 35 | Source string `json:"source"` 36 | Scopes map[string]interface{} `json:"scopes"` 37 | Text string `json:"text"` 38 | FullText string `json:"full_text"` 39 | DisplayTextRange Indices `json:"display_text_range"` 40 | Place *Place `json:"place"` 41 | Truncated bool `json:"truncated"` 42 | User *User `json:"user"` 43 | WithheldCopyright bool `json:"withheld_copyright"` 44 | WithheldInCountries []string `json:"withheld_in_countries"` 45 | WithheldScope string `json:"withheld_scope"` 46 | ExtendedEntities *ExtendedEntity `json:"extended_entities"` 47 | ExtendedTweet *ExtendedTweet `json:"extended_tweet"` 48 | QuotedStatusID int64 `json:"quoted_status_id"` 49 | QuotedStatusIDStr string `json:"quoted_status_id_str"` 50 | QuotedStatus *Tweet `json:"quoted_status"` 51 | } 52 | 53 | // CreatedAtTime returns the time a tweet was created. 54 | func (t Tweet) CreatedAtTime() (time.Time, error) { 55 | return time.Parse(time.RubyDate, t.CreatedAt) 56 | } 57 | 58 | // ExtendedTweet represents fields embedded in extended Tweets when served in 59 | // compatibility mode (default). 60 | // https://dev.twitter.com/overview/api/upcoming-changes-to-tweets 61 | type ExtendedTweet struct { 62 | FullText string `json:"full_text"` 63 | DisplayTextRange Indices `json:"display_text_range"` 64 | Entities *Entities `json:"entities"` 65 | ExtendedEntities *ExtendedEntity `json:"extended_entities"` 66 | } 67 | 68 | // Place represents a Twitter Place / Location 69 | // https://dev.twitter.com/overview/api/places 70 | type Place struct { 71 | Attributes map[string]string `json:"attributes"` 72 | BoundingBox *BoundingBox `json:"bounding_box"` 73 | Country string `json:"country"` 74 | CountryCode string `json:"country_code"` 75 | FullName string `json:"full_name"` 76 | Geometry *BoundingBox `json:"geometry"` 77 | ID string `json:"id"` 78 | Name string `json:"name"` 79 | PlaceType string `json:"place_type"` 80 | Polylines []string `json:"polylines"` 81 | URL string `json:"url"` 82 | } 83 | 84 | // BoundingBox represents the bounding coordinates (longitude, latitutde) 85 | // defining the bounds of a box containing a Place entity. 86 | type BoundingBox struct { 87 | Coordinates [][][2]float64 `json:"coordinates"` 88 | Type string `json:"type"` 89 | } 90 | 91 | // Coordinates are pairs of longitude and latitude locations. 92 | type Coordinates struct { 93 | Coordinates [2]float64 `json:"coordinates"` 94 | Type string `json:"type"` 95 | } 96 | 97 | // TweetIdentifier represents the id by which a Tweet can be identified. 98 | type TweetIdentifier struct { 99 | ID int64 `json:"id"` 100 | IDStr string `json:"id_str"` 101 | } 102 | 103 | // StatusService provides methods for accessing Twitter status API endpoints. 104 | type StatusService struct { 105 | sling *sling.Sling 106 | } 107 | 108 | // newStatusService returns a new StatusService. 109 | func newStatusService(sling *sling.Sling) *StatusService { 110 | return &StatusService{ 111 | sling: sling.Path("statuses/"), 112 | } 113 | } 114 | 115 | // StatusShowParams are the parameters for StatusService.Show 116 | type StatusShowParams struct { 117 | ID int64 `url:"id,omitempty"` 118 | TrimUser *bool `url:"trim_user,omitempty"` 119 | IncludeMyRetweet *bool `url:"include_my_retweet,omitempty"` 120 | IncludeEntities *bool `url:"include_entities,omitempty"` 121 | TweetMode string `url:"tweet_mode,omitempty"` 122 | } 123 | 124 | // Show returns the requested Tweet. 125 | // https://developer.twitter.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/get-statuses-show-id 126 | func (s *StatusService) Show(id int64, params *StatusShowParams) (*Tweet, *http.Response, error) { 127 | if params == nil { 128 | params = &StatusShowParams{} 129 | } 130 | params.ID = id 131 | tweet := new(Tweet) 132 | apiError := new(APIError) 133 | resp, err := s.sling.New().Get("show.json").QueryStruct(params).Receive(tweet, apiError) 134 | return tweet, resp, relevantError(err, *apiError) 135 | } 136 | 137 | // StatusLookupParams are the parameters for StatusService.Lookup 138 | type StatusLookupParams struct { 139 | ID []int64 `url:"id,omitempty,comma"` 140 | TrimUser *bool `url:"trim_user,omitempty"` 141 | IncludeEntities *bool `url:"include_entities,omitempty"` 142 | Map *bool `url:"map,omitempty"` 143 | TweetMode string `url:"tweet_mode,omitempty"` 144 | } 145 | 146 | // Lookup returns the requested Tweets as a slice. Combines ids from the 147 | // required ids argument and from params.Id. 148 | // https://developer.twitter.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/get-statuses-lookup 149 | func (s *StatusService) Lookup(ids []int64, params *StatusLookupParams) ([]Tweet, *http.Response, error) { 150 | if params == nil { 151 | params = &StatusLookupParams{} 152 | } 153 | params.ID = append(params.ID, ids...) 154 | tweets := new([]Tweet) 155 | apiError := new(APIError) 156 | resp, err := s.sling.New().Get("lookup.json").QueryStruct(params).Receive(tweets, apiError) 157 | return *tweets, resp, relevantError(err, *apiError) 158 | } 159 | 160 | // StatusUpdateParams are the parameters for StatusService.Update 161 | type StatusUpdateParams struct { 162 | Status string `url:"status,omitempty"` 163 | InReplyToStatusID int64 `url:"in_reply_to_status_id,omitempty"` 164 | AutoPopulateReplyMetadata *bool `url:"auto_populate_reply_metadata,omitempty"` 165 | ExcludeReplyUserIds []int64 `url:"exclude_reply_user_ids,comma,omitempty"` 166 | AttachmentURL string `url:"attachment_url,omitempty"` 167 | MediaIds []int64 `url:"media_ids,omitempty,comma"` 168 | PossiblySensitive *bool `url:"possibly_sensitive,omitempty"` 169 | Lat *float64 `url:"lat,omitempty"` 170 | Long *float64 `url:"long,omitempty"` 171 | PlaceID string `url:"place_id,omitempty"` 172 | DisplayCoordinates *bool `url:"display_coordinates,omitempty"` 173 | TrimUser *bool `url:"trim_user,omitempty"` 174 | CardURI string `url:"card_uri,omitempty"` 175 | // Deprecated 176 | TweetMode string `url:"tweet_mode,omitempty"` 177 | } 178 | 179 | // Update updates the user's status, also known as Tweeting. 180 | // Requires a user auth context. 181 | // https://developer.twitter.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/post-statuses-update 182 | func (s *StatusService) Update(status string, params *StatusUpdateParams) (*Tweet, *http.Response, error) { 183 | if params == nil { 184 | params = &StatusUpdateParams{} 185 | } 186 | params.Status = status 187 | tweet := new(Tweet) 188 | apiError := new(APIError) 189 | resp, err := s.sling.New().Post("update.json").BodyForm(params).Receive(tweet, apiError) 190 | return tweet, resp, relevantError(err, *apiError) 191 | } 192 | 193 | // StatusRetweetParams are the parameters for StatusService.Retweet 194 | type StatusRetweetParams struct { 195 | ID int64 `url:"id,omitempty"` 196 | TrimUser *bool `url:"trim_user,omitempty"` 197 | TweetMode string `url:"tweet_mode,omitempty"` 198 | } 199 | 200 | // Retweet retweets the Tweet with the given id and returns the original Tweet 201 | // with embedded retweet details. 202 | // Requires a user auth context. 203 | // https://developer.twitter.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/post-statuses-retweet-id 204 | func (s *StatusService) Retweet(id int64, params *StatusRetweetParams) (*Tweet, *http.Response, error) { 205 | if params == nil { 206 | params = &StatusRetweetParams{} 207 | } 208 | params.ID = id 209 | tweet := new(Tweet) 210 | apiError := new(APIError) 211 | path := fmt.Sprintf("retweet/%d.json", params.ID) 212 | resp, err := s.sling.New().Post(path).BodyForm(params).Receive(tweet, apiError) 213 | return tweet, resp, relevantError(err, *apiError) 214 | } 215 | 216 | // StatusUnretweetParams are the parameters for StatusService.Unretweet 217 | type StatusUnretweetParams struct { 218 | ID int64 `url:"id,omitempty"` 219 | TrimUser *bool `url:"trim_user,omitempty"` 220 | TweetMode string `url:"tweet_mode,omitempty"` 221 | } 222 | 223 | // Unretweet unretweets the Tweet with the given id and returns the original Tweet. 224 | // Requires a user auth context. 225 | // https://developer.twitter.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/post-statuses-unretweet-id 226 | func (s *StatusService) Unretweet(id int64, params *StatusUnretweetParams) (*Tweet, *http.Response, error) { 227 | if params == nil { 228 | params = &StatusUnretweetParams{} 229 | } 230 | params.ID = id 231 | tweet := new(Tweet) 232 | apiError := new(APIError) 233 | path := fmt.Sprintf("unretweet/%d.json", params.ID) 234 | resp, err := s.sling.New().Post(path).BodyForm(params).Receive(tweet, apiError) 235 | return tweet, resp, relevantError(err, *apiError) 236 | } 237 | 238 | // StatusRetweetsParams are the parameters for StatusService.Retweets 239 | type StatusRetweetsParams struct { 240 | ID int64 `url:"id,omitempty"` 241 | Count int `url:"count,omitempty"` 242 | TrimUser *bool `url:"trim_user,omitempty"` 243 | TweetMode string `url:"tweet_mode,omitempty"` 244 | } 245 | 246 | // Retweets returns the most recent retweets of the Tweet with the given id. 247 | // https://developer.twitter.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/get-statuses-retweets-id 248 | func (s *StatusService) Retweets(id int64, params *StatusRetweetsParams) ([]Tweet, *http.Response, error) { 249 | if params == nil { 250 | params = &StatusRetweetsParams{} 251 | } 252 | params.ID = id 253 | tweets := new([]Tweet) 254 | apiError := new(APIError) 255 | path := fmt.Sprintf("retweets/%d.json", params.ID) 256 | resp, err := s.sling.New().Get(path).QueryStruct(params).Receive(tweets, apiError) 257 | return *tweets, resp, relevantError(err, *apiError) 258 | } 259 | 260 | // StatusRetweeterParams are the parameters for StatusService.Retweeters. 261 | type StatusRetweeterParams struct { 262 | ID int64 `url:"id,omitempty"` 263 | Count int `url:"count,omitempty"` 264 | Cursor int64 `url:"cursor,omitempty"` 265 | } 266 | 267 | // RetweeterIDs is a cursored collection of User IDs that retweeted a Tweet. 268 | type RetweeterIDs struct { 269 | IDs []int64 `json:"ids"` 270 | PreviousCursor int64 `json:"previous_cursor"` 271 | PreviousCursorStr string `json:"previous_cursor_str"` 272 | NextCursor int64 `json:"next_cursor"` 273 | NextCursorStr string `json:"next_cursor_str"` 274 | } 275 | 276 | // Retweeters return the retweeters of a specific tweet. 277 | // https://developer.twitter.com/en/docs/tweets/post-and-engage/api-reference/get-statuses-retweeters-ids 278 | func (s *StatusService) Retweeters(params *StatusRetweeterParams) (*RetweeterIDs, *http.Response, error) { 279 | retweeters := new(RetweeterIDs) 280 | apiError := new(APIError) 281 | resp, err := s.sling.Get("retweeters/ids.json").QueryStruct(params).Receive(retweeters, apiError) 282 | return retweeters, resp, relevantError(err, *apiError) 283 | } 284 | 285 | // StatusDestroyParams are the parameters for StatusService.Destroy 286 | type StatusDestroyParams struct { 287 | ID int64 `url:"id,omitempty"` 288 | TrimUser *bool `url:"trim_user,omitempty"` 289 | TweetMode string `url:"tweet_mode,omitempty"` 290 | } 291 | 292 | // Destroy deletes the Tweet with the given id and returns it if successful. 293 | // Requires a user auth context. 294 | // https://developer.twitter.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/post-favorites-destroy 295 | func (s *StatusService) Destroy(id int64, params *StatusDestroyParams) (*Tweet, *http.Response, error) { 296 | if params == nil { 297 | params = &StatusDestroyParams{} 298 | } 299 | params.ID = id 300 | tweet := new(Tweet) 301 | apiError := new(APIError) 302 | path := fmt.Sprintf("destroy/%d.json", params.ID) 303 | resp, err := s.sling.New().Post(path).BodyForm(params).Receive(tweet, apiError) 304 | return tweet, resp, relevantError(err, *apiError) 305 | } 306 | 307 | // OEmbedTweet represents a Tweet in oEmbed format. 308 | type OEmbedTweet struct { 309 | URL string `json:"url"` 310 | ProviderURL string `json:"provider_url"` 311 | ProviderName string `json:"provider_name"` 312 | AuthorName string `json:"author_name"` 313 | Version string `json:"version"` 314 | AuthorURL string `json:"author_url"` 315 | Type string `json:"type"` 316 | HTML string `json:"html"` 317 | Height int64 `json:"height"` 318 | Width int64 `json:"width"` 319 | CacheAge string `json:"cache_age"` 320 | } 321 | 322 | // StatusOEmbedParams are the parameters for StatusService.OEmbed 323 | type StatusOEmbedParams struct { 324 | ID int64 `url:"id,omitempty"` 325 | URL string `url:"url,omitempty"` 326 | Align string `url:"align,omitempty"` 327 | MaxWidth int64 `url:"maxwidth,omitempty"` 328 | HideMedia *bool `url:"hide_media,omitempty"` 329 | HideThread *bool `url:"hide_media,omitempty"` 330 | OmitScript *bool `url:"hide_media,omitempty"` 331 | WidgetType string `url:"widget_type,omitempty"` 332 | HideTweet *bool `url:"hide_tweet,omitempty"` 333 | } 334 | 335 | // OEmbed returns the requested Tweet in oEmbed format. 336 | // https://developer.twitter.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/get-statuses-oembed 337 | func (s *StatusService) OEmbed(params *StatusOEmbedParams) (*OEmbedTweet, *http.Response, error) { 338 | oEmbedTweet := new(OEmbedTweet) 339 | apiError := new(APIError) 340 | resp, err := s.sling.New().Get("oembed.json").QueryStruct(params).Receive(oEmbedTweet, apiError) 341 | return oEmbedTweet, resp, relevantError(err, *apiError) 342 | } 343 | -------------------------------------------------------------------------------- /twitter/statuses_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestStatusService_Show(t *testing.T) { 13 | httpClient, mux, server := testServer() 14 | defer server.Close() 15 | 16 | mux.HandleFunc("/1.1/statuses/show.json", func(w http.ResponseWriter, r *http.Request) { 17 | assertMethod(t, "GET", r) 18 | assertQuery(t, map[string]string{"id": "589488862814076930", "include_entities": "false"}, r) 19 | w.Header().Set("Content-Type", "application/json") 20 | fmt.Fprintf(w, `{"user": {"screen_name": "dghubble"}, "text": ".@audreyr use a DONTREADME file if you really want people to read it :P"}`) 21 | }) 22 | 23 | client := NewClient(httpClient) 24 | params := &StatusShowParams{ID: 5441, IncludeEntities: Bool(false)} 25 | tweet, _, err := client.Statuses.Show(589488862814076930, params) 26 | expected := &Tweet{User: &User{ScreenName: "dghubble"}, Text: ".@audreyr use a DONTREADME file if you really want people to read it :P"} 27 | assert.Nil(t, err) 28 | assert.Equal(t, expected, tweet) 29 | } 30 | 31 | func TestStatusService_ShowHandlesNilParams(t *testing.T) { 32 | httpClient, mux, server := testServer() 33 | defer server.Close() 34 | 35 | mux.HandleFunc("/1.1/statuses/show.json", func(w http.ResponseWriter, r *http.Request) { 36 | assertQuery(t, map[string]string{"id": "589488862814076930"}, r) 37 | }) 38 | client := NewClient(httpClient) 39 | _, _, err := client.Statuses.Show(589488862814076930, nil) 40 | assert.Nil(t, err) 41 | } 42 | 43 | func TestStatusService_Lookup(t *testing.T) { 44 | httpClient, mux, server := testServer() 45 | defer server.Close() 46 | 47 | mux.HandleFunc("/1.1/statuses/lookup.json", func(w http.ResponseWriter, r *http.Request) { 48 | assertMethod(t, "GET", r) 49 | assertQuery(t, map[string]string{"id": "20,573893817000140800", "trim_user": "true"}, r) 50 | w.Header().Set("Content-Type", "application/json") 51 | fmt.Fprintf(w, `[{"id": 20, "text": "just setting up my twttr"}, {"id": 573893817000140800, "text": "Don't get lost #PaxEast2015"}]`) 52 | }) 53 | 54 | client := NewClient(httpClient) 55 | params := &StatusLookupParams{ID: []int64{20}, TrimUser: Bool(true)} 56 | tweets, _, err := client.Statuses.Lookup([]int64{573893817000140800}, params) 57 | expected := []Tweet{{ID: 20, Text: "just setting up my twttr"}, {ID: 573893817000140800, Text: "Don't get lost #PaxEast2015"}} 58 | assert.Nil(t, err) 59 | assert.Equal(t, expected, tweets) 60 | } 61 | 62 | func TestStatusService_LookupHandlesNilParams(t *testing.T) { 63 | httpClient, mux, server := testServer() 64 | defer server.Close() 65 | mux.HandleFunc("/1.1/statuses/lookup.json", func(w http.ResponseWriter, r *http.Request) { 66 | assertQuery(t, map[string]string{"id": "20,573893817000140800"}, r) 67 | }) 68 | client := NewClient(httpClient) 69 | _, _, err := client.Statuses.Lookup([]int64{20, 573893817000140800}, nil) 70 | assert.Nil(t, err) 71 | } 72 | 73 | func TestStatusService_Update(t *testing.T) { 74 | httpClient, mux, server := testServer() 75 | defer server.Close() 76 | 77 | mux.HandleFunc("/1.1/statuses/update.json", func(w http.ResponseWriter, r *http.Request) { 78 | assertMethod(t, "POST", r) 79 | assertQuery(t, map[string]string{}, r) 80 | assertPostForm(t, map[string]string{"status": "very informative tweet", "media_ids": "123456789,987654321", "lat": "37.826706", "long": "-122.42219"}, r) 81 | w.Header().Set("Content-Type", "application/json") 82 | fmt.Fprintf(w, `{"id": 581980947630845953, "text": "very informative tweet"}`) 83 | }) 84 | 85 | client := NewClient(httpClient) 86 | params := &StatusUpdateParams{MediaIds: []int64{123456789, 987654321}, Lat: Float(37.826706), Long: Float(-122.422190)} 87 | tweet, _, err := client.Statuses.Update("very informative tweet", params) 88 | expected := &Tweet{ID: 581980947630845953, Text: "very informative tweet"} 89 | assert.Nil(t, err) 90 | assert.Equal(t, expected, tweet) 91 | } 92 | 93 | func TestStatusService_UpdateHandlesNilParams(t *testing.T) { 94 | httpClient, mux, server := testServer() 95 | defer server.Close() 96 | mux.HandleFunc("/1.1/statuses/update.json", func(w http.ResponseWriter, r *http.Request) { 97 | assertPostForm(t, map[string]string{"status": "very informative tweet"}, r) 98 | }) 99 | client := NewClient(httpClient) 100 | _, _, err := client.Statuses.Update("very informative tweet", nil) 101 | assert.Nil(t, err) 102 | } 103 | 104 | func TestStatusService_APIError(t *testing.T) { 105 | httpClient, mux, server := testServer() 106 | defer server.Close() 107 | mux.HandleFunc("/1.1/statuses/update.json", func(w http.ResponseWriter, r *http.Request) { 108 | assertPostForm(t, map[string]string{"status": "very informative tweet"}, r) 109 | w.Header().Set("Content-Type", "application/json") 110 | w.WriteHeader(403) 111 | fmt.Fprintf(w, `{"errors": [{"message": "Status is a duplicate", "code": 187}]}`) 112 | }) 113 | 114 | client := NewClient(httpClient) 115 | _, _, err := client.Statuses.Update("very informative tweet", nil) 116 | expected := APIError{ 117 | Errors: []ErrorDetail{ 118 | {Message: "Status is a duplicate", Code: 187}, 119 | }, 120 | } 121 | if assert.Error(t, err) { 122 | assert.Equal(t, expected, err) 123 | } 124 | } 125 | 126 | func TestStatusService_HTTPError(t *testing.T) { 127 | httpClient, _, server := testServer() 128 | server.Close() 129 | client := NewClient(httpClient) 130 | _, _, err := client.Statuses.Update("very informative tweet", nil) 131 | if err == nil || !strings.Contains(err.Error(), "connection refused") { 132 | t.Errorf("Statuses.Update error expected connection refused, got: \n %+v", err) 133 | } 134 | } 135 | 136 | func TestStatusService_Retweet(t *testing.T) { 137 | httpClient, mux, server := testServer() 138 | defer server.Close() 139 | 140 | mux.HandleFunc("/1.1/statuses/retweet/20.json", func(w http.ResponseWriter, r *http.Request) { 141 | assertMethod(t, "POST", r) 142 | assertQuery(t, map[string]string{}, r) 143 | assertPostForm(t, map[string]string{"id": "20", "trim_user": "true"}, r) 144 | w.Header().Set("Content-Type", "application/json") 145 | fmt.Fprintf(w, `{"id": 581980947630202020, "text": "RT @jack: just setting up my twttr", "retweeted_status": {"id": 20, "text": "just setting up my twttr"}}`) 146 | }) 147 | 148 | client := NewClient(httpClient) 149 | params := &StatusRetweetParams{TrimUser: Bool(true)} 150 | tweet, _, err := client.Statuses.Retweet(20, params) 151 | expected := &Tweet{ID: 581980947630202020, Text: "RT @jack: just setting up my twttr", RetweetedStatus: &Tweet{ID: 20, Text: "just setting up my twttr"}} 152 | assert.Nil(t, err) 153 | assert.Equal(t, expected, tweet) 154 | } 155 | 156 | func TestStatusService_RetweetHandlesNilParams(t *testing.T) { 157 | httpClient, mux, server := testServer() 158 | defer server.Close() 159 | 160 | mux.HandleFunc("/1.1/statuses/retweet/20.json", func(w http.ResponseWriter, r *http.Request) { 161 | assertPostForm(t, map[string]string{"id": "20"}, r) 162 | }) 163 | 164 | client := NewClient(httpClient) 165 | _, _, err := client.Statuses.Retweet(20, nil) 166 | assert.Nil(t, err) 167 | } 168 | 169 | func TestStatusService_Unretweet(t *testing.T) { 170 | httpClient, mux, server := testServer() 171 | defer server.Close() 172 | 173 | mux.HandleFunc("/1.1/statuses/unretweet/20.json", func(w http.ResponseWriter, r *http.Request) { 174 | assertMethod(t, "POST", r) 175 | assertQuery(t, map[string]string{}, r) 176 | assertPostForm(t, map[string]string{"id": "20", "trim_user": "true"}, r) 177 | w.Header().Set("Content-Type", "application/json") 178 | fmt.Fprintf(w, `{"id": 581980947630202020, "text":"RT @jack: just setting up my twttr", "retweeted_status": {"id": 20, "text": "just setting up my twttr"}}`) 179 | }) 180 | 181 | client := NewClient(httpClient) 182 | params := &StatusUnretweetParams{TrimUser: Bool(true)} 183 | tweet, _, err := client.Statuses.Unretweet(20, params) 184 | expected := &Tweet{ID: 581980947630202020, Text: "RT @jack: just setting up my twttr", RetweetedStatus: &Tweet{ID: 20, Text: "just setting up my twttr"}} 185 | assert.Nil(t, err) 186 | assert.Equal(t, expected, tweet) 187 | } 188 | 189 | func TestStatusService_Retweets(t *testing.T) { 190 | httpClient, mux, server := testServer() 191 | defer server.Close() 192 | 193 | mux.HandleFunc("/1.1/statuses/retweets/20.json", func(w http.ResponseWriter, r *http.Request) { 194 | assertMethod(t, "GET", r) 195 | assertQuery(t, map[string]string{"id": "20", "count": "2"}, r) 196 | w.Header().Set("Content-Type", "application/json") 197 | fmt.Fprintf(w, `[{"text": "RT @jack: just setting up my twttr"}, {"text": "RT @jack: just setting up my twttr"}]`) 198 | }) 199 | 200 | client := NewClient(httpClient) 201 | params := &StatusRetweetsParams{Count: 2} 202 | retweets, _, err := client.Statuses.Retweets(20, params) 203 | expected := []Tweet{{Text: "RT @jack: just setting up my twttr"}, {Text: "RT @jack: just setting up my twttr"}} 204 | assert.Nil(t, err) 205 | assert.Equal(t, expected, retweets) 206 | } 207 | 208 | func TestStatusService_RetweetsHandlesNilParams(t *testing.T) { 209 | httpClient, mux, server := testServer() 210 | defer server.Close() 211 | 212 | mux.HandleFunc("/1.1/statuses/retweets/20.json", func(w http.ResponseWriter, r *http.Request) { 213 | assertQuery(t, map[string]string{"id": "20"}, r) 214 | }) 215 | 216 | client := NewClient(httpClient) 217 | _, _, err := client.Statuses.Retweets(20, nil) 218 | assert.Nil(t, err) 219 | } 220 | 221 | func TestStatusService_Retweeters(t *testing.T) { 222 | httpClient, mux, server := testServer() 223 | defer server.Close() 224 | 225 | mux.HandleFunc("/1.1/statuses/retweeters/ids.json", func(w http.ResponseWriter, r *http.Request) { 226 | assertMethod(t, "GET", r) 227 | assertQuery(t, map[string]string{"id": "1554515292390408192", "count": "10"}, r) 228 | w.Header().Set("Content-Type", "application/json") 229 | fmt.Fprintf(w, `{ 230 | "ids": [316736642], 231 | "previous_cursor": 0, 232 | "previous_cursor_str": "0", 233 | "next_cursor": 0, 234 | "next_cursor_str": "0" 235 | }`) 236 | }) 237 | 238 | client := NewClient(httpClient) 239 | params := &StatusRetweeterParams{ 240 | ID: 1554515292390408192, 241 | Count: 10, 242 | } 243 | tweets, _, err := client.Statuses.Retweeters(params) 244 | expected := &RetweeterIDs{ 245 | IDs: []int64{316736642}, 246 | PreviousCursor: 0, 247 | PreviousCursorStr: "0", 248 | NextCursor: 0, 249 | NextCursorStr: "0", 250 | } 251 | assert.Nil(t, err) 252 | assert.Equal(t, expected, tweets) 253 | } 254 | 255 | func TestStatusService_Destroy(t *testing.T) { 256 | httpClient, mux, server := testServer() 257 | defer server.Close() 258 | 259 | mux.HandleFunc("/1.1/statuses/destroy/40.json", func(w http.ResponseWriter, r *http.Request) { 260 | assertMethod(t, "POST", r) 261 | assertQuery(t, map[string]string{}, r) 262 | assertPostForm(t, map[string]string{"id": "40", "trim_user": "true"}, r) 263 | w.Header().Set("Content-Type", "application/json") 264 | fmt.Fprintf(w, `{"id": 40, "text": "wishing I had another sammich"}`) 265 | }) 266 | 267 | client := NewClient(httpClient) 268 | params := &StatusDestroyParams{TrimUser: Bool(true)} 269 | tweet, _, err := client.Statuses.Destroy(40, params) 270 | // feed Biz Stone a sammich, he deletes sammich Tweet 271 | expected := &Tweet{ID: 40, Text: "wishing I had another sammich"} 272 | assert.Nil(t, err) 273 | assert.Equal(t, expected, tweet) 274 | } 275 | 276 | func TestStatusService_DestroyHandlesNilParams(t *testing.T) { 277 | httpClient, mux, server := testServer() 278 | defer server.Close() 279 | 280 | mux.HandleFunc("/1.1/statuses/destroy/40.json", func(w http.ResponseWriter, r *http.Request) { 281 | assertPostForm(t, map[string]string{"id": "40"}, r) 282 | }) 283 | 284 | client := NewClient(httpClient) 285 | _, _, err := client.Statuses.Destroy(40, nil) 286 | assert.Nil(t, err) 287 | } 288 | 289 | func TestStatusService_OEmbed(t *testing.T) { 290 | httpClient, mux, server := testServer() 291 | defer server.Close() 292 | 293 | mux.HandleFunc("/1.1/statuses/oembed.json", func(w http.ResponseWriter, r *http.Request) { 294 | assertMethod(t, "GET", r) 295 | assertQuery(t, map[string]string{"id": "691076766878691329", "maxwidth": "400", "hide_media": "true"}, r) 296 | w.Header().Set("Content-Type", "application/json") 297 | // abbreviated oEmbed response 298 | fmt.Fprintf(w, `{"url": "https://twitter.com/dghubble/statuses/691076766878691329", "width": 400, "html": "
"}`) 299 | }) 300 | 301 | client := NewClient(httpClient) 302 | params := &StatusOEmbedParams{ 303 | ID: 691076766878691329, 304 | MaxWidth: 400, 305 | HideMedia: Bool(true), 306 | } 307 | oembed, _, err := client.Statuses.OEmbed(params) 308 | expected := &OEmbedTweet{ 309 | URL: "https://twitter.com/dghubble/statuses/691076766878691329", 310 | Width: 400, 311 | HTML: "
", 312 | } 313 | assert.Nil(t, err) 314 | assert.Equal(t, expected, oembed) 315 | } 316 | -------------------------------------------------------------------------------- /twitter/stream_messages.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | // StatusDeletion indicates that a given Tweet has been deleted. 4 | // https://dev.twitter.com/streaming/overview/messages-types#status_deletion_notices_delete 5 | type StatusDeletion struct { 6 | ID int64 `json:"id"` 7 | IDStr string `json:"id_str"` 8 | UserID int64 `json:"user_id"` 9 | UserIDStr string `json:"user_id_str"` 10 | } 11 | 12 | type statusDeletionNotice struct { 13 | Delete struct { 14 | StatusDeletion *StatusDeletion `json:"status"` 15 | } `json:"delete"` 16 | } 17 | 18 | // LocationDeletion indicates geolocation data must be stripped from a range 19 | // of Tweets. 20 | // https://dev.twitter.com/streaming/overview/messages-types#Location_deletion_notices_scrub_geo 21 | type LocationDeletion struct { 22 | UserID int64 `json:"user_id"` 23 | UserIDStr string `json:"user_id_str"` 24 | UpToStatusID int64 `json:"up_to_status_id"` 25 | UpToStatusIDStr string `json:"up_to_status_id_str"` 26 | } 27 | 28 | type locationDeletionNotice struct { 29 | ScrubGeo *LocationDeletion `json:"scrub_geo"` 30 | } 31 | 32 | // StreamLimit indicates a stream matched more statuses than its rate limit 33 | // allowed. The track number is the number of undelivered matches. 34 | // https://dev.twitter.com/streaming/overview/messages-types#limit_notices 35 | type StreamLimit struct { 36 | Track int64 `json:"track"` 37 | } 38 | 39 | type streamLimitNotice struct { 40 | Limit *StreamLimit `json:"limit"` 41 | } 42 | 43 | // StatusWithheld indicates a Tweet with the given ID, belonging to UserId, 44 | // has been withheld in certain countries. 45 | // https://dev.twitter.com/streaming/overview/messages-types#withheld_content_notices 46 | type StatusWithheld struct { 47 | ID int64 `json:"id"` 48 | UserID int64 `json:"user_id"` 49 | WithheldInCountries []string `json:"withheld_in_countries"` 50 | } 51 | 52 | type statusWithheldNotice struct { 53 | StatusWithheld *StatusWithheld `json:"status_withheld"` 54 | } 55 | 56 | // UserWithheld indicates a User with the given ID has been withheld in 57 | // certain countries. 58 | // https://dev.twitter.com/streaming/overview/messages-types#withheld_content_notices 59 | type UserWithheld struct { 60 | ID int64 `json:"id"` 61 | WithheldInCountries []string `json:"withheld_in_countries"` 62 | } 63 | type userWithheldNotice struct { 64 | UserWithheld *UserWithheld `json:"user_withheld"` 65 | } 66 | 67 | // StreamDisconnect indicates the stream has been shutdown for some reason. 68 | // https://dev.twitter.com/streaming/overview/messages-types#disconnect_messages 69 | type StreamDisconnect struct { 70 | Code int64 `json:"code"` 71 | StreamName string `json:"stream_name"` 72 | Reason string `json:"reason"` 73 | } 74 | 75 | type streamDisconnectNotice struct { 76 | StreamDisconnect *StreamDisconnect `json:"disconnect"` 77 | } 78 | 79 | // StallWarning indicates the client is falling behind in the stream. 80 | // https://dev.twitter.com/streaming/overview/messages-types#stall_warnings 81 | type StallWarning struct { 82 | Code string `json:"code"` 83 | Message string `json:"message"` 84 | PercentFull int `json:"percent_full"` 85 | } 86 | 87 | type stallWarningNotice struct { 88 | StallWarning *StallWarning `json:"warning"` 89 | } 90 | 91 | // FriendsList is a list of some of a user's friends. 92 | // https://dev.twitter.com/streaming/overview/messages-types#friends_list_friends 93 | type FriendsList struct { 94 | Friends []int64 `json:"friends"` 95 | } 96 | 97 | type directMessageNotice struct { 98 | DirectMessage *DirectMessage `json:"direct_message"` 99 | } 100 | 101 | // Event is a non-Tweet notification message (e.g. like, retweet, follow). 102 | // https://dev.twitter.com/streaming/overview/messages-types#Events_event 103 | type Event struct { 104 | Event string `json:"event"` 105 | CreatedAt string `json:"created_at"` 106 | Target *User `json:"target"` 107 | Source *User `json:"source"` 108 | // TODO: add List or deprecate it 109 | TargetObject *Tweet `json:"target_object"` 110 | } 111 | -------------------------------------------------------------------------------- /twitter/stream_utils.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "io" 7 | "time" 8 | ) 9 | 10 | // stopped returns true if the done channel receives, false otherwise. 11 | func stopped(done <-chan struct{}) bool { 12 | select { 13 | case <-done: 14 | return true 15 | default: 16 | return false 17 | } 18 | } 19 | 20 | // sleepOrDone pauses the current goroutine until the done channel receives 21 | // or until at least the duration d has elapsed, whichever comes first. This 22 | // is similar to time.Sleep(d), except it can be interrupted. 23 | func sleepOrDone(d time.Duration, done <-chan struct{}) { 24 | sleep := time.NewTimer(d) 25 | defer sleep.Stop() 26 | select { 27 | case <-sleep.C: 28 | return 29 | case <-done: 30 | return 31 | } 32 | } 33 | 34 | // streamResponseBodyReader is a buffered reader for Twitter stream response 35 | // body. It can scan the arbitrary length of response body unlike bufio.Scanner. 36 | type streamResponseBodyReader struct { 37 | reader *bufio.Reader 38 | buf bytes.Buffer 39 | } 40 | 41 | // newStreamResponseBodyReader returns an instance of streamResponseBodyReader 42 | // for the given Twitter stream response body. 43 | func newStreamResponseBodyReader(body io.Reader) *streamResponseBodyReader { 44 | return &streamResponseBodyReader{reader: bufio.NewReader(body)} 45 | } 46 | 47 | // readNext reads Twitter stream response body and returns the next stream 48 | // content if exists. Returns io.EOF error if we reached the end of the stream 49 | // and there's no more message to read. 50 | func (r *streamResponseBodyReader) readNext() ([]byte, error) { 51 | // Discard all the bytes from buf and continue to use the allocated memory 52 | // space for reading the next message. 53 | r.buf.Truncate(0) 54 | for { 55 | // Twitter stream messages are separated with "\r\n", and a valid 56 | // message may sometimes contain '\n' in the middle. 57 | // bufio.Reader.Read() can accept one byte delimiter only, so we need to 58 | // first break out each line on '\n' and then check whether the line ends 59 | // with "\r\n" to find message boundaries. 60 | // https://dev.twitter.com/streaming/overview/processing 61 | line, err := r.reader.ReadBytes('\n') 62 | // Non-EOF error should be propagated to callers immediately. 63 | if err != nil && err != io.EOF { 64 | return nil, err 65 | } 66 | // EOF error means that we reached the end of the stream body before finding 67 | // delimiter '\n'. If "line" is empty, it means the reader didn't read any 68 | // data from the stream before reaching EOF and there's nothing to append to 69 | // buf. 70 | if err == io.EOF && len(line) == 0 { 71 | // if buf has no data, propagate io.EOF to callers and let them know that 72 | // we've finished processing the stream. 73 | if r.buf.Len() == 0 { 74 | return nil, err 75 | } 76 | // Otherwise, we still have a remaining stream message to return. 77 | break 78 | } 79 | // If the line ends with "\r\n", it's the end of one stream message data. 80 | if bytes.HasSuffix(line, []byte("\r\n")) { 81 | // reader.ReadBytes() returns a slice including the delimiter itself, so 82 | // we need to trim '\n' as well as '\r' from the end of the slice. 83 | r.buf.Write(bytes.TrimRight(line, "\r\n")) 84 | break 85 | } 86 | // Otherwise, the line is not the end of a stream message, so we append 87 | // the line to buf and continue to scan lines. 88 | r.buf.Write(line) 89 | } 90 | 91 | // Get the stream message bytes from buf. Not that Bytes() won't mark the 92 | // returned data as "read", and we need to explicitly call Truncate(0) to 93 | // discard from buf before writing the next stream message to buf. 94 | return r.buf.Bytes(), nil 95 | } 96 | -------------------------------------------------------------------------------- /twitter/stream_utils_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "io" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestStopped(t *testing.T) { 15 | done := make(chan struct{}) 16 | assert.False(t, stopped(done)) 17 | close(done) 18 | assert.True(t, stopped(done)) 19 | } 20 | 21 | func TestSleepOrDone_Sleep(t *testing.T) { 22 | wait := time.Nanosecond * 20 23 | done := make(chan struct{}) 24 | completed := make(chan struct{}) 25 | go func() { 26 | sleepOrDone(wait, done) 27 | close(completed) 28 | }() 29 | // wait for goroutine SleepOrDone to sleep 30 | assertDone(t, completed, defaultTestTimeout) 31 | } 32 | 33 | func TestSleepOrDone_Done(t *testing.T) { 34 | wait := time.Second * 5 35 | done := make(chan struct{}) 36 | completed := make(chan struct{}) 37 | go func() { 38 | sleepOrDone(wait, done) 39 | close(completed) 40 | }() 41 | // close done, interrupting SleepOrDone 42 | close(done) 43 | // assert that SleepOrDone exited, closing completed 44 | assertDone(t, completed, defaultTestTimeout) 45 | } 46 | 47 | func TestStreamResponseBodyReader(t *testing.T) { 48 | cases := []struct { 49 | in []byte 50 | want [][]byte 51 | }{ 52 | { 53 | in: []byte("foo\r\nbar\r\n"), 54 | want: [][]byte{ 55 | []byte("foo"), 56 | []byte("bar"), 57 | }, 58 | }, 59 | { 60 | in: []byte("foo\nbar\r\n"), 61 | want: [][]byte{ 62 | []byte("foo\nbar"), 63 | }, 64 | }, 65 | { 66 | in: []byte("foo\r\n\r\n"), 67 | want: [][]byte{ 68 | []byte("foo"), 69 | []byte(""), 70 | }, 71 | }, 72 | { 73 | in: []byte("foo\r\nbar"), 74 | want: [][]byte{ 75 | []byte("foo"), 76 | []byte("bar"), 77 | }, 78 | }, 79 | { 80 | // Message length is more than bufio.MaxScanTokenSize, which can't be 81 | // parsed by bufio.Scanner with default buffer size. 82 | in: []byte(strings.Repeat("X", bufio.MaxScanTokenSize+1) + "\r\n"), 83 | want: [][]byte{ 84 | []byte(strings.Repeat("X", bufio.MaxScanTokenSize+1)), 85 | }, 86 | }, 87 | } 88 | 89 | for _, c := range cases { 90 | body := bytes.NewReader(c.in) 91 | reader := newStreamResponseBodyReader(body) 92 | 93 | for i, want := range c.want { 94 | data, err := reader.readNext() 95 | if err != nil { 96 | t.Errorf("reader(%q).readNext() * %d: err == %q, want nil", c.in, i, err) 97 | } 98 | if !bytes.Equal(data, want) { 99 | t.Errorf("reader(%q).readNext() * %d: data == %q, want %q", c.in, i, data, want) 100 | } 101 | } 102 | 103 | data, err := reader.readNext() 104 | if err != io.EOF { 105 | t.Errorf("reader(%q).readNext() * %d: err == %q, want io.EOF", c.in, len(c.want), err) 106 | } 107 | if len(data) != 0 { 108 | t.Errorf("reader(%q).readNext() * %d: data == %q, want \"\"", c.in, len(c.want), data) 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /twitter/streams.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | "sync" 8 | "time" 9 | 10 | "github.com/cenkalti/backoff/v4" 11 | "github.com/dghubble/sling" 12 | ) 13 | 14 | const ( 15 | userAgent = "go-twitter v0.1" 16 | publicStream = "https://stream.twitter.com/1.1/" 17 | userStream = "https://userstream.twitter.com/1.1/" 18 | siteStream = "https://sitestream.twitter.com/1.1/" 19 | ) 20 | 21 | // StreamService provides methods for accessing the Twitter Streaming API. 22 | type StreamService struct { 23 | client *http.Client 24 | public *sling.Sling 25 | user *sling.Sling 26 | site *sling.Sling 27 | } 28 | 29 | // newStreamService returns a new StreamService. 30 | func newStreamService(client *http.Client, sling *sling.Sling) *StreamService { 31 | sling.Set("User-Agent", userAgent) 32 | return &StreamService{ 33 | client: client, 34 | public: sling.New().Base(publicStream).Path("statuses/"), 35 | user: sling.New().Base(userStream), 36 | site: sling.New().Base(siteStream), 37 | } 38 | } 39 | 40 | // StreamFilterParams are parameters for StreamService.Filter. 41 | type StreamFilterParams struct { 42 | FilterLevel string `url:"filter_level,omitempty"` 43 | Follow []string `url:"follow,omitempty,comma"` 44 | Language []string `url:"language,omitempty,comma"` 45 | Locations []string `url:"locations,omitempty,comma"` 46 | StallWarnings *bool `url:"stall_warnings,omitempty"` 47 | Track []string `url:"track,omitempty,comma"` 48 | } 49 | 50 | // Filter returns messages that match one or more filter predicates. 51 | // https://dev.twitter.com/streaming/reference/post/statuses/filter 52 | func (srv *StreamService) Filter(params *StreamFilterParams) (*Stream, error) { 53 | req, err := srv.public.New().Post("filter.json").QueryStruct(params).Request() 54 | if err != nil { 55 | return nil, err 56 | } 57 | return newStream(srv.client, req), nil 58 | } 59 | 60 | // StreamSampleParams are the parameters for StreamService.Sample. 61 | type StreamSampleParams struct { 62 | StallWarnings *bool `url:"stall_warnings,omitempty"` 63 | Language []string `url:"language,omitempty,comma"` 64 | } 65 | 66 | // Sample returns a small sample of public stream messages. 67 | // https://dev.twitter.com/streaming/reference/get/statuses/sample 68 | func (srv *StreamService) Sample(params *StreamSampleParams) (*Stream, error) { 69 | req, err := srv.public.New().Get("sample.json").QueryStruct(params).Request() 70 | if err != nil { 71 | return nil, err 72 | } 73 | return newStream(srv.client, req), nil 74 | } 75 | 76 | // StreamUserParams are the parameters for StreamService.User. 77 | type StreamUserParams struct { 78 | FilterLevel string `url:"filter_level,omitempty"` 79 | Language []string `url:"language,omitempty,comma"` 80 | Locations []string `url:"locations,omitempty,comma"` 81 | Replies string `url:"replies,omitempty"` 82 | StallWarnings *bool `url:"stall_warnings,omitempty"` 83 | Track []string `url:"track,omitempty,comma"` 84 | With string `url:"with,omitempty"` 85 | } 86 | 87 | // User returns a stream of messages specific to the authenticated User. 88 | // https://dev.twitter.com/streaming/reference/get/user 89 | func (srv *StreamService) User(params *StreamUserParams) (*Stream, error) { 90 | req, err := srv.user.New().Get("user.json").QueryStruct(params).Request() 91 | if err != nil { 92 | return nil, err 93 | } 94 | return newStream(srv.client, req), nil 95 | } 96 | 97 | // StreamSiteParams are the parameters for StreamService.Site. 98 | type StreamSiteParams struct { 99 | FilterLevel string `url:"filter_level,omitempty"` 100 | Follow []string `url:"follow,omitempty,comma"` 101 | Language []string `url:"language,omitempty,comma"` 102 | Replies string `url:"replies,omitempty"` 103 | StallWarnings *bool `url:"stall_warnings,omitempty"` 104 | With string `url:"with,omitempty"` 105 | } 106 | 107 | // Site returns messages for a set of users. 108 | // Requires special permission to access. 109 | // https://dev.twitter.com/streaming/reference/get/site 110 | func (srv *StreamService) Site(params *StreamSiteParams) (*Stream, error) { 111 | req, err := srv.site.New().Get("site.json").QueryStruct(params).Request() 112 | if err != nil { 113 | return nil, err 114 | } 115 | return newStream(srv.client, req), nil 116 | } 117 | 118 | // StreamFirehoseParams are the parameters for StreamService.Firehose. 119 | type StreamFirehoseParams struct { 120 | Count int `url:"count,omitempty"` 121 | FilterLevel string `url:"filter_level,omitempty"` 122 | Language []string `url:"language,omitempty,comma"` 123 | StallWarnings *bool `url:"stall_warnings,omitempty"` 124 | } 125 | 126 | // Firehose returns all public messages and statuses. 127 | // Requires special permission to access. 128 | // https://dev.twitter.com/streaming/reference/get/statuses/firehose 129 | func (srv *StreamService) Firehose(params *StreamFirehoseParams) (*Stream, error) { 130 | req, err := srv.public.New().Get("firehose.json").QueryStruct(params).Request() 131 | if err != nil { 132 | return nil, err 133 | } 134 | return newStream(srv.client, req), nil 135 | } 136 | 137 | // Stream maintains a connection to the Twitter Streaming API, receives 138 | // messages from the streaming response, and sends them on the Messages 139 | // channel from a goroutine. The stream goroutine stops itself if an EOF is 140 | // reached or retry errors occur, also closing the Messages channel. 141 | // 142 | // The client must Stop() the stream when finished receiving, which will 143 | // wait until the stream is properly stopped. 144 | type Stream struct { 145 | client *http.Client 146 | Messages chan interface{} 147 | done chan struct{} 148 | group *sync.WaitGroup 149 | body io.Closer 150 | } 151 | 152 | // newStream creates a Stream and starts a goroutine to retry connecting and 153 | // receive from a stream response. The goroutine may stop due to retry errors 154 | // or be stopped by calling Stop() on the stream. 155 | func newStream(client *http.Client, req *http.Request) *Stream { 156 | s := &Stream{ 157 | client: client, 158 | Messages: make(chan interface{}), 159 | done: make(chan struct{}), 160 | group: &sync.WaitGroup{}, 161 | } 162 | s.group.Add(1) 163 | go s.retry(req, newExponentialBackOff(), newAggressiveExponentialBackOff()) 164 | return s 165 | } 166 | 167 | // Stop signals retry and receiver to stop, closes the Messages channel, and 168 | // blocks until done. 169 | func (s *Stream) Stop() { 170 | close(s.done) 171 | // Scanner does not have a Stop() or take a done channel, so for low volume 172 | // streams Scan() blocks until the next keep-alive. Close the resp.Body to 173 | // escape and stop the stream in a timely fashion. 174 | if s.body != nil { 175 | s.body.Close() 176 | } 177 | // block until the retry goroutine stops 178 | s.group.Wait() 179 | } 180 | 181 | // retry retries making the given http.Request and receiving the response 182 | // according to the Twitter backoff policies. Callers should invoke in a 183 | // goroutine since backoffs sleep between retries. 184 | // https://dev.twitter.com/streaming/overview/connecting 185 | func (s *Stream) retry(req *http.Request, expBackOff backoff.BackOff, aggExpBackOff backoff.BackOff) { 186 | // close Messages channel and decrement the wait group counter 187 | defer close(s.Messages) 188 | defer s.group.Done() 189 | 190 | var wait time.Duration 191 | for !stopped(s.done) { 192 | resp, err := s.client.Do(req) 193 | if err != nil { 194 | // stop retrying for HTTP protocol errors 195 | s.Messages <- err 196 | return 197 | } 198 | // when err is nil, resp contains a non-nil Body which must be closed 199 | defer resp.Body.Close() 200 | s.body = resp.Body 201 | switch resp.StatusCode { 202 | case 200: 203 | // receive stream response Body, handles closing 204 | s.receive(resp.Body) 205 | expBackOff.Reset() 206 | aggExpBackOff.Reset() 207 | case 503: 208 | // exponential backoff 209 | wait = expBackOff.NextBackOff() 210 | case 420, 429: 211 | // aggressive exponential backoff 212 | wait = aggExpBackOff.NextBackOff() 213 | default: 214 | // stop retrying for other response codes 215 | resp.Body.Close() 216 | return 217 | } 218 | // close response before each retry 219 | resp.Body.Close() 220 | if wait == backoff.Stop { 221 | return 222 | } 223 | sleepOrDone(wait, s.done) 224 | } 225 | } 226 | 227 | // receive scans a stream response body, JSON decodes tokens to messages, and 228 | // sends messages to the Messages channel. Receiving continues until an EOF, 229 | // scan error, or the done channel is closed. 230 | func (s *Stream) receive(body io.Reader) { 231 | reader := newStreamResponseBodyReader(body) 232 | for !stopped(s.done) { 233 | data, err := reader.readNext() 234 | if err != nil { 235 | return 236 | } 237 | if len(data) == 0 { 238 | // empty keep-alive 239 | continue 240 | } 241 | select { 242 | // send messages, data, or errors 243 | case s.Messages <- getMessage(data): 244 | continue 245 | // allow client to Stop(), even if not receiving 246 | case <-s.done: 247 | return 248 | } 249 | } 250 | } 251 | 252 | // getMessage unmarshals the token and returns a message struct, if the type 253 | // can be determined. Otherwise, returns the token unmarshalled into a data 254 | // map[string]interface{} or the unmarshal error. 255 | func getMessage(token []byte) interface{} { 256 | var data map[string]interface{} 257 | // unmarshal JSON encoded token into a map for 258 | err := json.Unmarshal(token, &data) 259 | if err != nil { 260 | return err 261 | } 262 | return decodeMessage(token, data) 263 | } 264 | 265 | // decodeMessage determines the message type from known data keys, allocates 266 | // at most one message struct, and JSON decodes the token into the message. 267 | // Returns the message struct or the data map if the message type could not be 268 | // determined. 269 | func decodeMessage(token []byte, data map[string]interface{}) interface{} { 270 | if hasPath(data, "retweet_count") { 271 | tweet := new(Tweet) 272 | json.Unmarshal(token, tweet) 273 | return tweet 274 | } else if hasPath(data, "direct_message") { 275 | notice := new(directMessageNotice) 276 | json.Unmarshal(token, notice) 277 | return notice.DirectMessage 278 | } else if hasPath(data, "delete") { 279 | notice := new(statusDeletionNotice) 280 | json.Unmarshal(token, notice) 281 | return notice.Delete.StatusDeletion 282 | } else if hasPath(data, "scrub_geo") { 283 | notice := new(locationDeletionNotice) 284 | json.Unmarshal(token, notice) 285 | return notice.ScrubGeo 286 | } else if hasPath(data, "limit") { 287 | notice := new(streamLimitNotice) 288 | json.Unmarshal(token, notice) 289 | return notice.Limit 290 | } else if hasPath(data, "status_withheld") { 291 | notice := new(statusWithheldNotice) 292 | json.Unmarshal(token, notice) 293 | return notice.StatusWithheld 294 | } else if hasPath(data, "user_withheld") { 295 | notice := new(userWithheldNotice) 296 | json.Unmarshal(token, notice) 297 | return notice.UserWithheld 298 | } else if hasPath(data, "disconnect") { 299 | notice := new(streamDisconnectNotice) 300 | json.Unmarshal(token, notice) 301 | return notice.StreamDisconnect 302 | } else if hasPath(data, "warning") { 303 | notice := new(stallWarningNotice) 304 | json.Unmarshal(token, notice) 305 | return notice.StallWarning 306 | } else if hasPath(data, "friends") { 307 | friendsList := new(FriendsList) 308 | json.Unmarshal(token, friendsList) 309 | return friendsList 310 | } else if hasPath(data, "event") { 311 | event := new(Event) 312 | json.Unmarshal(token, event) 313 | return event 314 | } 315 | // message type unknown, return the data map[string]interface{} 316 | return data 317 | } 318 | 319 | // hasPath returns true if the map contains the given key, false otherwise. 320 | func hasPath(data map[string]interface{}, key string) bool { 321 | _, ok := data[key] 322 | return ok 323 | } 324 | -------------------------------------------------------------------------------- /twitter/streams_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "sync" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestStream_MessageJSONError(t *testing.T) { 14 | badJSON := []byte(`{`) 15 | msg := getMessage(badJSON) 16 | assert.EqualError(t, msg.(error), "unexpected end of JSON input") 17 | } 18 | 19 | func TestStream_GetMessageTweet(t *testing.T) { 20 | msgJSON := []byte(`{"id": 20, "text": "just setting up my twttr", "retweet_count": "68535"}`) 21 | msg := getMessage(msgJSON) 22 | assert.IsType(t, &Tweet{}, msg) 23 | } 24 | 25 | func TestStream_GetMessageDirectMessage(t *testing.T) { 26 | msgJSON := []byte(`{"direct_message": {"id": 666024290140217347}}`) 27 | msg := getMessage(msgJSON) 28 | assert.IsType(t, &DirectMessage{}, msg) 29 | } 30 | 31 | func TestStream_GetMessageDelete(t *testing.T) { 32 | msgJSON := []byte(`{"delete": { "id": 20}}`) 33 | msg := getMessage(msgJSON) 34 | assert.IsType(t, &StatusDeletion{}, msg) 35 | } 36 | 37 | func TestStream_GetMessageLocationDeletion(t *testing.T) { 38 | msgJSON := []byte(`{"scrub_geo": { "up_to_status_id": 20}}`) 39 | msg := getMessage(msgJSON) 40 | assert.IsType(t, &LocationDeletion{}, msg) 41 | } 42 | 43 | func TestStream_GetMessageStreamLimit(t *testing.T) { 44 | msgJSON := []byte(`{"limit": { "track": 10 }}`) 45 | msg := getMessage(msgJSON) 46 | assert.IsType(t, &StreamLimit{}, msg) 47 | } 48 | 49 | func TestStream_StatusWithheld(t *testing.T) { 50 | msgJSON := []byte(`{"status_withheld": { "id": 20, "user_id": 12, "withheld_in_countries":["USA", "China"] }}`) 51 | msg := getMessage(msgJSON) 52 | assert.IsType(t, &StatusWithheld{}, msg) 53 | } 54 | 55 | func TestStream_UserWithheld(t *testing.T) { 56 | msgJSON := []byte(`{"user_withheld": { "id": 12, "withheld_in_countries":["USA", "China"] }}`) 57 | msg := getMessage(msgJSON) 58 | assert.IsType(t, &UserWithheld{}, msg) 59 | } 60 | 61 | func TestStream_StreamDisconnect(t *testing.T) { 62 | msgJSON := []byte(`{"disconnect": { "code": "420", "stream_name": "streaming stuff", "reason": "too many connections" }}`) 63 | msg := getMessage(msgJSON) 64 | assert.IsType(t, &StreamDisconnect{}, msg) 65 | } 66 | 67 | func TestStream_StallWarning(t *testing.T) { 68 | msgJSON := []byte(`{"warning": { "code": "420", "percent_full": 90, "message": "a lot of messages" }}`) 69 | msg := getMessage(msgJSON) 70 | assert.IsType(t, &StallWarning{}, msg) 71 | } 72 | 73 | func TestStream_FriendsList(t *testing.T) { 74 | msgJSON := []byte(`{"friends": [666024290140217347, 666024290140217349, 666024290140217342]}`) 75 | msg := getMessage(msgJSON) 76 | assert.IsType(t, &FriendsList{}, msg) 77 | } 78 | 79 | func TestStream_Event(t *testing.T) { 80 | msgJSON := []byte(`{"event": "block", "target": {"name": "XKCD Comic", "favourites_count": 2}, "source": {"name": "XKCD Comic2", "favourites_count": 3}, "created_at": "Sat Sep 4 16:10:54 +0000 2010"}`) 81 | msg := getMessage(msgJSON) 82 | assert.IsType(t, &Event{}, msg) 83 | } 84 | 85 | func TestStream_Unknown(t *testing.T) { 86 | msgJSON := []byte(`{"unknown_data": {"new_twitter_type":"unexpected"}}`) 87 | msg := getMessage(msgJSON) 88 | assert.IsType(t, map[string]interface{}{}, msg) 89 | } 90 | 91 | func TestStream_Filter(t *testing.T) { 92 | httpClient, mux, server := testServer() 93 | defer server.Close() 94 | 95 | reqCount := 0 96 | mux.HandleFunc("/1.1/statuses/filter.json", func(w http.ResponseWriter, r *http.Request) { 97 | assertMethod(t, "POST", r) 98 | assertQuery(t, map[string]string{"track": "gophercon,golang"}, r) 99 | switch reqCount { 100 | case 0: 101 | w.Header().Set("Content-Type", "application/json") 102 | w.Header().Set("Transfer-Encoding", "chunked") 103 | fmt.Fprintf(w, 104 | `{"text": "Gophercon talks!"}`+"\r\n"+ 105 | `{"text": "Gophercon super talks!"}`+"\r\n", 106 | ) 107 | default: 108 | // Only allow first request 109 | http.Error(w, "Stream API not available!", 500) 110 | } 111 | reqCount++ 112 | }) 113 | 114 | counts := &counter{} 115 | demux := newCounterDemux(counts) 116 | client := NewClient(httpClient) 117 | streamFilterParams := &StreamFilterParams{ 118 | Track: []string{"gophercon", "golang"}, 119 | } 120 | stream, err := client.Streams.Filter(streamFilterParams) 121 | // assert that the expected messages are received 122 | assert.NoError(t, err) 123 | defer stream.Stop() 124 | for message := range stream.Messages { 125 | demux.Handle(message) 126 | } 127 | expectedCounts := &counter{all: 2, other: 2} 128 | assert.Equal(t, expectedCounts, counts) 129 | } 130 | 131 | func TestStream_Sample(t *testing.T) { 132 | httpClient, mux, server := testServer() 133 | defer server.Close() 134 | 135 | reqCount := 0 136 | mux.HandleFunc("/1.1/statuses/sample.json", func(w http.ResponseWriter, r *http.Request) { 137 | assertMethod(t, "GET", r) 138 | assertQuery(t, map[string]string{"stall_warnings": "true"}, r) 139 | switch reqCount { 140 | case 0: 141 | w.Header().Set("Content-Type", "application/json") 142 | w.Header().Set("Transfer-Encoding", "chunked") 143 | fmt.Fprintf(w, 144 | `{"text": "Gophercon talks!"}`+"\r\n"+ 145 | `{"text": "Gophercon super talks!"}`+"\r\n", 146 | ) 147 | default: 148 | // Only allow first request 149 | http.Error(w, "Stream API not available!", 500) 150 | } 151 | reqCount++ 152 | }) 153 | 154 | counts := &counter{} 155 | demux := newCounterDemux(counts) 156 | client := NewClient(httpClient) 157 | streamSampleParams := &StreamSampleParams{ 158 | StallWarnings: Bool(true), 159 | } 160 | stream, err := client.Streams.Sample(streamSampleParams) 161 | // assert that the expected messages are received 162 | assert.NoError(t, err) 163 | defer stream.Stop() 164 | for message := range stream.Messages { 165 | demux.Handle(message) 166 | } 167 | expectedCounts := &counter{all: 2, other: 2} 168 | assert.Equal(t, expectedCounts, counts) 169 | } 170 | 171 | func TestStream_User(t *testing.T) { 172 | httpClient, mux, server := testServer() 173 | defer server.Close() 174 | 175 | reqCount := 0 176 | mux.HandleFunc("/1.1/user.json", func(w http.ResponseWriter, r *http.Request) { 177 | assertMethod(t, "GET", r) 178 | assertQuery(t, map[string]string{"stall_warnings": "true", "with": "followings"}, r) 179 | switch reqCount { 180 | case 0: 181 | w.Header().Set("Content-Type", "application/json") 182 | w.Header().Set("Transfer-Encoding", "chunked") 183 | fmt.Fprintf(w, `{"friends": [666024290140217347, 666024290140217349, 666024290140217342]}`+"\r\n"+"\r\n") 184 | default: 185 | // Only allow first request 186 | http.Error(w, "Stream API not available!", 500) 187 | } 188 | reqCount++ 189 | }) 190 | 191 | counts := &counter{} 192 | demux := newCounterDemux(counts) 193 | client := NewClient(httpClient) 194 | streamUserParams := &StreamUserParams{ 195 | StallWarnings: Bool(true), 196 | With: "followings", 197 | } 198 | stream, err := client.Streams.User(streamUserParams) 199 | // assert that the expected messages are received 200 | assert.NoError(t, err) 201 | defer stream.Stop() 202 | for message := range stream.Messages { 203 | demux.Handle(message) 204 | } 205 | expectedCounts := &counter{all: 1, friendsList: 1} 206 | assert.Equal(t, expectedCounts, counts) 207 | } 208 | 209 | func TestStream_User_TooManyFriends(t *testing.T) { 210 | httpClient, mux, server := testServer() 211 | defer server.Close() 212 | 213 | reqCount := 0 214 | mux.HandleFunc("/1.1/user.json", func(w http.ResponseWriter, r *http.Request) { 215 | assertMethod(t, "GET", r) 216 | assertQuery(t, map[string]string{"stall_warnings": "true", "with": "followings"}, r) 217 | switch reqCount { 218 | case 0: 219 | w.Header().Set("Content-Type", "application/json") 220 | w.Header().Set("Transfer-Encoding", "chunked") 221 | // The first friend list message is more than bufio.MaxScanTokenSize (65536) bytes 222 | friendsList := "[" + strings.Repeat("1234567890, ", 7000) + "1234567890]" 223 | fmt.Fprintf(w, `{"friends": %s}`+"\r\n"+"\r\n", friendsList) 224 | default: 225 | // Only allow first request 226 | http.Error(w, "Stream API not available!", 500) 227 | } 228 | reqCount++ 229 | }) 230 | 231 | counts := &counter{} 232 | demux := newCounterDemux(counts) 233 | client := NewClient(httpClient) 234 | streamUserParams := &StreamUserParams{ 235 | StallWarnings: Bool(true), 236 | With: "followings", 237 | } 238 | stream, err := client.Streams.User(streamUserParams) 239 | // assert that the expected messages are received 240 | assert.NoError(t, err) 241 | defer stream.Stop() 242 | for message := range stream.Messages { 243 | demux.Handle(message) 244 | } 245 | expectedCounts := &counter{all: 1, friendsList: 1} 246 | assert.Equal(t, expectedCounts, counts) 247 | } 248 | 249 | func TestStream_Site(t *testing.T) { 250 | httpClient, mux, server := testServer() 251 | defer server.Close() 252 | 253 | reqCount := 0 254 | mux.HandleFunc("/1.1/site.json", func(w http.ResponseWriter, r *http.Request) { 255 | assertMethod(t, "GET", r) 256 | assertQuery(t, map[string]string{"follow": "666024290140217347,666024290140217349"}, r) 257 | switch reqCount { 258 | case 0: 259 | w.Header().Set("Content-Type", "application/json") 260 | w.Header().Set("Transfer-Encoding", "chunked") 261 | fmt.Fprintf(w, 262 | `{"text": "Gophercon talks!"}`+"\r\n"+ 263 | `{"text": "Gophercon super talks!"}`+"\r\n", 264 | ) 265 | default: 266 | // Only allow first request 267 | http.Error(w, "Stream API not available!", 500) 268 | } 269 | reqCount++ 270 | }) 271 | 272 | counts := &counter{} 273 | demux := newCounterDemux(counts) 274 | client := NewClient(httpClient) 275 | streamSiteParams := &StreamSiteParams{ 276 | Follow: []string{"666024290140217347", "666024290140217349"}, 277 | } 278 | stream, err := client.Streams.Site(streamSiteParams) 279 | // assert that the expected messages are received 280 | assert.NoError(t, err) 281 | defer stream.Stop() 282 | for message := range stream.Messages { 283 | demux.Handle(message) 284 | } 285 | expectedCounts := &counter{all: 2, other: 2} 286 | assert.Equal(t, expectedCounts, counts) 287 | } 288 | 289 | func TestStream_PublicFirehose(t *testing.T) { 290 | httpClient, mux, server := testServer() 291 | defer server.Close() 292 | 293 | reqCount := 0 294 | mux.HandleFunc("/1.1/statuses/firehose.json", func(w http.ResponseWriter, r *http.Request) { 295 | assertMethod(t, "GET", r) 296 | assertQuery(t, map[string]string{"count": "100"}, r) 297 | switch reqCount { 298 | case 0: 299 | w.Header().Set("Content-Type", "application/json") 300 | w.Header().Set("Transfer-Encoding", "chunked") 301 | fmt.Fprintf(w, 302 | `{"text": "Gophercon talks!"}`+"\r\n"+ 303 | `{"text": "Gophercon super talks!"}`+"\r\n", 304 | ) 305 | default: 306 | // Only allow first request 307 | http.Error(w, "Stream API not available!", 500) 308 | } 309 | reqCount++ 310 | }) 311 | 312 | counts := &counter{} 313 | demux := newCounterDemux(counts) 314 | client := NewClient(httpClient) 315 | streamFirehoseParams := &StreamFirehoseParams{ 316 | Count: 100, 317 | } 318 | stream, err := client.Streams.Firehose(streamFirehoseParams) 319 | // assert that the expected messages are received 320 | assert.NoError(t, err) 321 | defer stream.Stop() 322 | for message := range stream.Messages { 323 | demux.Handle(message) 324 | } 325 | expectedCounts := &counter{all: 2, other: 2} 326 | assert.Equal(t, expectedCounts, counts) 327 | } 328 | 329 | func TestStreamRetry_ExponentialBackoff(t *testing.T) { 330 | httpClient, mux, server := testServer() 331 | defer server.Close() 332 | 333 | reqCount := 0 334 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 335 | switch reqCount { 336 | case 0: 337 | http.Error(w, "Service Unavailable", 503) 338 | default: 339 | // Only allow first request 340 | http.Error(w, "Stream API not available!", 500) 341 | } 342 | reqCount++ 343 | }) 344 | stream := &Stream{ 345 | client: httpClient, 346 | Messages: make(chan interface{}), 347 | done: make(chan struct{}), 348 | group: &sync.WaitGroup{}, 349 | } 350 | stream.group.Add(1) 351 | req, _ := http.NewRequest("GET", "http://example.com/", nil) 352 | expBackoff := &BackOffRecorder{} 353 | // receive messages and throw them away 354 | go NewSwitchDemux().HandleChan(stream.Messages) 355 | stream.retry(req, expBackoff, nil) 356 | defer stream.Stop() 357 | // assert exponential backoff in response to 503 358 | assert.Equal(t, 1, expBackoff.Count) 359 | } 360 | 361 | func TestStreamRetry_AggressiveBackoff(t *testing.T) { 362 | httpClient, mux, server := testServer() 363 | defer server.Close() 364 | 365 | reqCount := 0 366 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 367 | switch reqCount { 368 | case 0: 369 | http.Error(w, "Enhance Your Calm", 420) 370 | case 1: 371 | http.Error(w, "Too Many Requests", 429) 372 | default: 373 | // Only allow first request 374 | http.Error(w, "Stream API not available!", 500) 375 | } 376 | reqCount++ 377 | }) 378 | stream := &Stream{ 379 | client: httpClient, 380 | Messages: make(chan interface{}), 381 | done: make(chan struct{}), 382 | group: &sync.WaitGroup{}, 383 | } 384 | stream.group.Add(1) 385 | req, _ := http.NewRequest("GET", "http://example.com/", nil) 386 | aggExpBackoff := &BackOffRecorder{} 387 | // receive messages and throw them away 388 | go NewSwitchDemux().HandleChan(stream.Messages) 389 | stream.retry(req, nil, aggExpBackoff) 390 | defer stream.Stop() 391 | // assert aggressive exponential backoff in response to 420 and 429 392 | assert.Equal(t, 2, aggExpBackoff.Count) 393 | } 394 | -------------------------------------------------------------------------------- /twitter/timelines.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/dghubble/sling" 7 | ) 8 | 9 | // TimelineService provides methods for accessing Twitter status timeline 10 | // API endpoints. 11 | type TimelineService struct { 12 | sling *sling.Sling 13 | } 14 | 15 | // newTimelineService returns a new TimelineService. 16 | func newTimelineService(sling *sling.Sling) *TimelineService { 17 | return &TimelineService{ 18 | sling: sling.Path("statuses/"), 19 | } 20 | } 21 | 22 | // UserTimelineParams are the parameters for TimelineService.UserTimeline. 23 | type UserTimelineParams struct { 24 | UserID int64 `url:"user_id,omitempty"` 25 | ScreenName string `url:"screen_name,omitempty"` 26 | Count int `url:"count,omitempty"` 27 | SinceID int64 `url:"since_id,omitempty"` 28 | MaxID int64 `url:"max_id,omitempty"` 29 | TrimUser *bool `url:"trim_user,omitempty"` 30 | ExcludeReplies *bool `url:"exclude_replies,omitempty"` 31 | IncludeRetweets *bool `url:"include_rts,omitempty"` 32 | TweetMode string `url:"tweet_mode,omitempty"` 33 | } 34 | 35 | // UserTimeline returns recent Tweets from the specified user. 36 | // https://dev.twitter.com/rest/reference/get/statuses/user_timeline 37 | func (s *TimelineService) UserTimeline(params *UserTimelineParams) ([]Tweet, *http.Response, error) { 38 | tweets := new([]Tweet) 39 | apiError := new(APIError) 40 | resp, err := s.sling.New().Get("user_timeline.json").QueryStruct(params).Receive(tweets, apiError) 41 | return *tweets, resp, relevantError(err, *apiError) 42 | } 43 | 44 | // HomeTimelineParams are the parameters for TimelineService.HomeTimeline. 45 | type HomeTimelineParams struct { 46 | Count int `url:"count,omitempty"` 47 | SinceID int64 `url:"since_id,omitempty"` 48 | MaxID int64 `url:"max_id,omitempty"` 49 | TrimUser *bool `url:"trim_user,omitempty"` 50 | ExcludeReplies *bool `url:"exclude_replies,omitempty"` 51 | ContributorDetails *bool `url:"contributor_details,omitempty"` 52 | IncludeEntities *bool `url:"include_entities,omitempty"` 53 | TweetMode string `url:"tweet_mode,omitempty"` 54 | } 55 | 56 | // HomeTimeline returns recent Tweets and retweets from the user and those 57 | // users they follow. 58 | // Requires a user auth context. 59 | // https://dev.twitter.com/rest/reference/get/statuses/home_timeline 60 | func (s *TimelineService) HomeTimeline(params *HomeTimelineParams) ([]Tweet, *http.Response, error) { 61 | tweets := new([]Tweet) 62 | apiError := new(APIError) 63 | resp, err := s.sling.New().Get("home_timeline.json").QueryStruct(params).Receive(tweets, apiError) 64 | return *tweets, resp, relevantError(err, *apiError) 65 | } 66 | 67 | // MentionTimelineParams are the parameters for TimelineService.MentionTimeline. 68 | type MentionTimelineParams struct { 69 | Count int `url:"count,omitempty"` 70 | SinceID int64 `url:"since_id,omitempty"` 71 | MaxID int64 `url:"max_id,omitempty"` 72 | TrimUser *bool `url:"trim_user,omitempty"` 73 | ContributorDetails *bool `url:"contributor_details,omitempty"` 74 | IncludeEntities *bool `url:"include_entities,omitempty"` 75 | TweetMode string `url:"tweet_mode,omitempty"` 76 | } 77 | 78 | // MentionTimeline returns recent Tweet mentions of the authenticated user. 79 | // Requires a user auth context. 80 | // https://dev.twitter.com/rest/reference/get/statuses/mentions_timeline 81 | func (s *TimelineService) MentionTimeline(params *MentionTimelineParams) ([]Tweet, *http.Response, error) { 82 | tweets := new([]Tweet) 83 | apiError := new(APIError) 84 | resp, err := s.sling.New().Get("mentions_timeline.json").QueryStruct(params).Receive(tweets, apiError) 85 | return *tweets, resp, relevantError(err, *apiError) 86 | } 87 | 88 | // RetweetsOfMeTimelineParams are the parameters for 89 | // TimelineService.RetweetsOfMeTimeline. 90 | type RetweetsOfMeTimelineParams struct { 91 | Count int `url:"count,omitempty"` 92 | SinceID int64 `url:"since_id,omitempty"` 93 | MaxID int64 `url:"max_id,omitempty"` 94 | TrimUser *bool `url:"trim_user,omitempty"` 95 | IncludeEntities *bool `url:"include_entities,omitempty"` 96 | IncludeUserEntities *bool `url:"include_user_entities"` 97 | TweetMode string `url:"tweet_mode,omitempty"` 98 | } 99 | 100 | // RetweetsOfMeTimeline returns the most recent Tweets by the authenticated 101 | // user that have been retweeted by others. 102 | // Requires a user auth context. 103 | // https://dev.twitter.com/rest/reference/get/statuses/retweets_of_me 104 | func (s *TimelineService) RetweetsOfMeTimeline(params *RetweetsOfMeTimelineParams) ([]Tweet, *http.Response, error) { 105 | tweets := new([]Tweet) 106 | apiError := new(APIError) 107 | resp, err := s.sling.New().Get("retweets_of_me.json").QueryStruct(params).Receive(tweets, apiError) 108 | return *tweets, resp, relevantError(err, *apiError) 109 | } 110 | -------------------------------------------------------------------------------- /twitter/timelines_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestTimelineService_UserTimeline(t *testing.T) { 12 | httpClient, mux, server := testServer() 13 | defer server.Close() 14 | 15 | mux.HandleFunc("/1.1/statuses/user_timeline.json", func(w http.ResponseWriter, r *http.Request) { 16 | assertMethod(t, "GET", r) 17 | assertQuery(t, map[string]string{"user_id": "113419064", "trim_user": "true", "include_rts": "false"}, r) 18 | w.Header().Set("Content-Type", "application/json") 19 | fmt.Fprintf(w, `[{"text": "Gophercon talks!"}, {"text": "Why gophers are so adorable"}]`) 20 | }) 21 | 22 | client := NewClient(httpClient) 23 | tweets, _, err := client.Timelines.UserTimeline(&UserTimelineParams{UserID: 113419064, TrimUser: Bool(true), IncludeRetweets: Bool(false)}) 24 | expected := []Tweet{{Text: "Gophercon talks!"}, {Text: "Why gophers are so adorable"}} 25 | assert.Nil(t, err) 26 | assert.Equal(t, expected, tweets) 27 | } 28 | 29 | func TestTimelineService_HomeTimeline(t *testing.T) { 30 | httpClient, mux, server := testServer() 31 | defer server.Close() 32 | 33 | mux.HandleFunc("/1.1/statuses/home_timeline.json", func(w http.ResponseWriter, r *http.Request) { 34 | assertMethod(t, "GET", r) 35 | assertQuery(t, map[string]string{"since_id": "589147592367431680", "exclude_replies": "false"}, r) 36 | w.Header().Set("Content-Type", "application/json") 37 | fmt.Fprintf(w, `[{"text": "Live on #Periscope"}, {"text": "Clickbait journalism"}, {"text": "Useful announcement"}]`) 38 | }) 39 | 40 | client := NewClient(httpClient) 41 | tweets, _, err := client.Timelines.HomeTimeline(&HomeTimelineParams{SinceID: 589147592367431680, ExcludeReplies: Bool(false)}) 42 | expected := []Tweet{{Text: "Live on #Periscope"}, {Text: "Clickbait journalism"}, {Text: "Useful announcement"}} 43 | assert.Nil(t, err) 44 | assert.Equal(t, expected, tweets) 45 | } 46 | 47 | func TestTimelineService_MentionTimeline(t *testing.T) { 48 | httpClient, mux, server := testServer() 49 | defer server.Close() 50 | 51 | mux.HandleFunc("/1.1/statuses/mentions_timeline.json", func(w http.ResponseWriter, r *http.Request) { 52 | assertMethod(t, "GET", r) 53 | assertQuery(t, map[string]string{"count": "20", "include_entities": "false"}, r) 54 | w.Header().Set("Content-Type", "application/json") 55 | fmt.Fprintf(w, `[{"text": "@dghubble can I get verified?"}, {"text": "@dghubble why are gophers so great?"}]`) 56 | }) 57 | 58 | client := NewClient(httpClient) 59 | tweets, _, err := client.Timelines.MentionTimeline(&MentionTimelineParams{Count: 20, IncludeEntities: Bool(false)}) 60 | expected := []Tweet{{Text: "@dghubble can I get verified?"}, {Text: "@dghubble why are gophers so great?"}} 61 | assert.Nil(t, err) 62 | assert.Equal(t, expected, tweets) 63 | } 64 | 65 | func TestTimelineService_RetweetsOfMeTimeline(t *testing.T) { 66 | httpClient, mux, server := testServer() 67 | defer server.Close() 68 | 69 | mux.HandleFunc("/1.1/statuses/retweets_of_me.json", func(w http.ResponseWriter, r *http.Request) { 70 | assertMethod(t, "GET", r) 71 | assertQuery(t, map[string]string{"trim_user": "false", "include_user_entities": "false"}, r) 72 | w.Header().Set("Content-Type", "application/json") 73 | fmt.Fprintf(w, `[{"text": "RT Twitter UK edition"}, {"text": "RT Triply-replicated Gophers"}]`) 74 | }) 75 | 76 | client := NewClient(httpClient) 77 | tweets, _, err := client.Timelines.RetweetsOfMeTimeline(&RetweetsOfMeTimelineParams{TrimUser: Bool(false), IncludeUserEntities: Bool(false)}) 78 | expected := []Tweet{{Text: "RT Twitter UK edition"}, {Text: "RT Triply-replicated Gophers"}} 79 | assert.Nil(t, err) 80 | assert.Equal(t, expected, tweets) 81 | } 82 | -------------------------------------------------------------------------------- /twitter/trends.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/dghubble/sling" 7 | ) 8 | 9 | // TrendsService provides methods for accessing Twitter trends API endpoints. 10 | type TrendsService struct { 11 | sling *sling.Sling 12 | } 13 | 14 | // newTrendsService returns a new TrendsService. 15 | func newTrendsService(sling *sling.Sling) *TrendsService { 16 | return &TrendsService{ 17 | sling: sling.Path("trends/"), 18 | } 19 | } 20 | 21 | // PlaceType represents a twitter trends PlaceType. 22 | type PlaceType struct { 23 | Code int `json:"code"` 24 | Name string `json:"name"` 25 | } 26 | 27 | // Location represents a twitter Location. 28 | type Location struct { 29 | Country string `json:"country"` 30 | CountryCode string `json:"countryCode"` 31 | Name string `json:"name"` 32 | ParentID int `json:"parentid"` 33 | PlaceType PlaceType `json:"placeType"` 34 | URL string `json:"url"` 35 | WOEID int64 `json:"woeid"` 36 | } 37 | 38 | // Available returns the locations that Twitter has trending topic information for. 39 | // https://dev.twitter.com/rest/reference/get/trends/available 40 | func (s *TrendsService) Available() ([]Location, *http.Response, error) { 41 | locations := new([]Location) 42 | apiError := new(APIError) 43 | resp, err := s.sling.New().Get("available.json").Receive(locations, apiError) 44 | return *locations, resp, relevantError(err, *apiError) 45 | } 46 | 47 | // Trend represents a twitter trend. 48 | type Trend struct { 49 | Name string `json:"name"` 50 | URL string `json:"url"` 51 | PromotedContent string `json:"promoted_content"` 52 | Query string `json:"query"` 53 | TweetVolume int64 `json:"tweet_volume"` 54 | } 55 | 56 | // TrendsList represents a list of twitter trends. 57 | type TrendsList struct { 58 | Trends []Trend `json:"trends"` 59 | AsOf string `json:"as_of"` 60 | CreatedAt string `json:"created_at"` 61 | Locations []TrendsLocation `json:"locations"` 62 | } 63 | 64 | // TrendsLocation represents a twitter trend location. 65 | type TrendsLocation struct { 66 | Name string `json:"name"` 67 | WOEID int64 `json:"woeid"` 68 | } 69 | 70 | // TrendsPlaceParams are the parameters for Trends.Place. 71 | type TrendsPlaceParams struct { 72 | WOEID int64 `url:"id,omitempty"` 73 | Exclude string `url:"exclude,omitempty"` 74 | } 75 | 76 | // Place returns the top 50 trending topics for a specific WOEID. 77 | // https://dev.twitter.com/rest/reference/get/trends/place 78 | func (s *TrendsService) Place(woeid int64, params *TrendsPlaceParams) ([]TrendsList, *http.Response, error) { 79 | if params == nil { 80 | params = &TrendsPlaceParams{} 81 | } 82 | trendsList := new([]TrendsList) 83 | params.WOEID = woeid 84 | apiError := new(APIError) 85 | resp, err := s.sling.New().Get("place.json").QueryStruct(params).Receive(trendsList, apiError) 86 | return *trendsList, resp, relevantError(err, *apiError) 87 | } 88 | 89 | // ClosestParams are the parameters for Trends.Closest. 90 | type ClosestParams struct { 91 | Lat float64 `url:"lat"` 92 | Long float64 `url:"long"` 93 | } 94 | 95 | // Closest returns the locations that Twitter has trending topic information for, closest to a specified location. 96 | // https://dev.twitter.com/rest/reference/get/trends/closest 97 | func (s *TrendsService) Closest(params *ClosestParams) ([]Location, *http.Response, error) { 98 | locations := new([]Location) 99 | apiError := new(APIError) 100 | resp, err := s.sling.New().Get("closest.json").QueryStruct(params).Receive(locations, apiError) 101 | return *locations, resp, relevantError(err, *apiError) 102 | } 103 | -------------------------------------------------------------------------------- /twitter/trends_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestTrendsService_Available(t *testing.T) { 12 | httpClient, mux, server := testServer() 13 | defer server.Close() 14 | 15 | mux.HandleFunc("/1.1/trends/available.json", func(w http.ResponseWriter, r *http.Request) { 16 | assertMethod(t, "GET", r) 17 | w.Header().Set("Content-Type", "application/json") 18 | fmt.Fprintf(w, `[{"country": "Sweden","countryCode": "SE","name": "Sweden","parentid": 1,"placeType": {"code": 12,"name": "Country"},"url": "http://where.yahooapis.com/v1/place/23424954","woeid": 23424954}]`) 19 | }) 20 | expected := []Location{ 21 | { 22 | Country: "Sweden", 23 | CountryCode: "SE", 24 | Name: "Sweden", 25 | ParentID: 1, 26 | PlaceType: PlaceType{Code: 12, Name: "Country"}, 27 | URL: "http://where.yahooapis.com/v1/place/23424954", 28 | WOEID: 23424954, 29 | }, 30 | } 31 | 32 | client := NewClient(httpClient) 33 | locations, _, err := client.Trends.Available() 34 | assert.Nil(t, err) 35 | assert.Equal(t, expected, locations) 36 | } 37 | 38 | func TestTrendsService_Place(t *testing.T) { 39 | httpClient, mux, server := testServer() 40 | defer server.Close() 41 | 42 | mux.HandleFunc("/1.1/trends/place.json", func(w http.ResponseWriter, r *http.Request) { 43 | assertMethod(t, "GET", r) 44 | assertQuery(t, map[string]string{"id": "123456"}, r) 45 | w.Header().Set("Content-Type", "application/json") 46 | fmt.Fprintf(w, `[{"trends":[{"name":"#gotwitter"}], "as_of": "2017-02-08T16:18:18Z", "created_at": "2017-02-08T16:10:33Z","locations":[{"name": "Worldwide","woeid": 1}]}]`) 47 | }) 48 | expected := []TrendsList{ 49 | { 50 | Trends: []Trend{ 51 | {Name: "#gotwitter"}, 52 | }, 53 | AsOf: "2017-02-08T16:18:18Z", 54 | CreatedAt: "2017-02-08T16:10:33Z", 55 | Locations: []TrendsLocation{ 56 | {Name: "Worldwide", WOEID: 1}, 57 | }, 58 | }, 59 | } 60 | 61 | client := NewClient(httpClient) 62 | places, _, err := client.Trends.Place(123456, &TrendsPlaceParams{}) 63 | assert.Nil(t, err) 64 | assert.Equal(t, expected, places) 65 | } 66 | 67 | func TestTrendsService_Closest(t *testing.T) { 68 | httpClient, mux, server := testServer() 69 | defer server.Close() 70 | 71 | mux.HandleFunc("/1.1/trends/closest.json", func(w http.ResponseWriter, r *http.Request) { 72 | assertMethod(t, "GET", r) 73 | assertQuery(t, map[string]string{"lat": "37.781157", "long": "-122.400612831116"}, r) 74 | w.Header().Set("Content-Type", "application/json") 75 | fmt.Fprintf(w, `[{"country": "Sweden","countryCode": "SE","name": "Sweden","parentid": 1,"placeType": {"code": 12,"name": "Country"},"url": "http://where.yahooapis.com/v1/place/23424954","woeid": 23424954}]`) 76 | }) 77 | expected := []Location{ 78 | { 79 | Country: "Sweden", 80 | CountryCode: "SE", 81 | Name: "Sweden", 82 | ParentID: 1, 83 | PlaceType: PlaceType{Code: 12, Name: "Country"}, 84 | URL: "http://where.yahooapis.com/v1/place/23424954", 85 | WOEID: 23424954, 86 | }, 87 | } 88 | 89 | client := NewClient(httpClient) 90 | locations, _, err := client.Trends.Closest(&ClosestParams{Lat: 37.781157, Long: -122.400612831116}) 91 | assert.Nil(t, err) 92 | assert.Equal(t, expected, locations) 93 | } 94 | -------------------------------------------------------------------------------- /twitter/twitter.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/dghubble/sling" 7 | ) 8 | 9 | const twitterAPI = "https://api.twitter.com/1.1/" 10 | 11 | // Client is a Twitter client for making Twitter API requests. 12 | type Client struct { 13 | sling *sling.Sling 14 | // Twitter API Services 15 | Accounts *AccountService 16 | Blocks *BlockService 17 | DirectMessages *DirectMessageService 18 | Favorites *FavoriteService 19 | Followers *FollowerService 20 | Friends *FriendService 21 | Friendships *FriendshipService 22 | Lists *ListsService 23 | RateLimits *RateLimitService 24 | Search *SearchService 25 | PremiumSearch *PremiumSearchService 26 | Statuses *StatusService 27 | Streams *StreamService 28 | Timelines *TimelineService 29 | Trends *TrendsService 30 | Users *UserService 31 | } 32 | 33 | // NewClient returns a new Client. 34 | func NewClient(httpClient *http.Client) *Client { 35 | base := sling.New().Client(httpClient).Base(twitterAPI) 36 | return &Client{ 37 | sling: base, 38 | Accounts: newAccountService(base.New()), 39 | Blocks: newBlockService(base.New()), 40 | DirectMessages: newDirectMessageService(base.New()), 41 | Favorites: newFavoriteService(base.New()), 42 | Followers: newFollowerService(base.New()), 43 | Friends: newFriendService(base.New()), 44 | Friendships: newFriendshipService(base.New()), 45 | Lists: newListService(base.New()), 46 | RateLimits: newRateLimitService(base.New()), 47 | Search: newSearchService(base.New()), 48 | PremiumSearch: newPremiumSearchService(base.New()), 49 | Statuses: newStatusService(base.New()), 50 | Streams: newStreamService(httpClient, base.New()), 51 | Timelines: newTimelineService(base.New()), 52 | Trends: newTrendsService(base.New()), 53 | Users: newUserService(base.New()), 54 | } 55 | } 56 | 57 | // Bool returns a new pointer to the given bool value. 58 | func Bool(v bool) *bool { 59 | ptr := new(bool) 60 | *ptr = v 61 | return ptr 62 | } 63 | 64 | // Float returns a new pointer to the given float64 value. 65 | func Float(v float64) *float64 { 66 | ptr := new(float64) 67 | *ptr = v 68 | return ptr 69 | } 70 | -------------------------------------------------------------------------------- /twitter/twitter_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "net/http/httptest" 7 | "net/url" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | var defaultTestTimeout = time.Second * 1 15 | 16 | // testServer returns an http Client, ServeMux, and Server. The client proxies 17 | // requests to the server and handlers can be registered on the mux to handle 18 | // requests. The caller must close the test server. 19 | func testServer() (*http.Client, *http.ServeMux, *httptest.Server) { 20 | mux := http.NewServeMux() 21 | server := httptest.NewServer(mux) 22 | transport := &RewriteTransport{&http.Transport{ 23 | Proxy: func(req *http.Request) (*url.URL, error) { 24 | return url.Parse(server.URL) 25 | }, 26 | }} 27 | client := &http.Client{Transport: transport} 28 | return client, mux, server 29 | } 30 | 31 | // RewriteTransport rewrites https requests to http to avoid TLS cert issues 32 | // during testing. 33 | type RewriteTransport struct { 34 | Transport http.RoundTripper 35 | } 36 | 37 | // RoundTrip rewrites the request scheme to http and calls through to the 38 | // composed RoundTripper or if it is nil, to the http.DefaultTransport. 39 | func (t *RewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { 40 | req.URL.Scheme = "http" 41 | if t.Transport == nil { 42 | return http.DefaultTransport.RoundTrip(req) 43 | } 44 | return t.Transport.RoundTrip(req) 45 | } 46 | 47 | func assertMethod(t *testing.T, expectedMethod string, req *http.Request) { 48 | assert.Equal(t, expectedMethod, req.Method) 49 | } 50 | 51 | // assertQuery tests that the Request has the expected url query key/val pairs 52 | func assertQuery(t *testing.T, expected map[string]string, req *http.Request) { 53 | queryValues := req.URL.Query() 54 | expectedValues := url.Values{} 55 | for key, value := range expected { 56 | expectedValues.Add(key, value) 57 | } 58 | assert.Equal(t, expectedValues, queryValues) 59 | } 60 | 61 | // assertPostForm tests that the Request has the expected key values pairs url 62 | // encoded in its Body 63 | func assertPostForm(t *testing.T, expected map[string]string, req *http.Request) { 64 | err := req.ParseForm() // parses request Body to put url.Values in r.Form/r.PostForm 65 | assert.Nil(t, err) 66 | expectedValues := url.Values{} 67 | for key, value := range expected { 68 | expectedValues.Add(key, value) 69 | } 70 | assert.Equal(t, expectedValues, req.Form) 71 | } 72 | 73 | // assertPostJSON tests that the Request has the expected JSON body. 74 | func assertPostJSON(t *testing.T, expected string, req *http.Request) { 75 | data, err := ioutil.ReadAll(req.Body) 76 | assert.Nil(t, err) 77 | assert.Equal(t, expected, string(data)) 78 | } 79 | 80 | // assertDone asserts that the empty struct channel is closed before the given 81 | // timeout elapses. 82 | func assertDone(t *testing.T, ch <-chan struct{}, timeout time.Duration) { 83 | select { 84 | case <-ch: 85 | _, more := <-ch 86 | assert.False(t, more) 87 | case <-time.After(timeout): 88 | t.Errorf("expected channel to be closed within timeout %v", timeout) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /twitter/users.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/dghubble/sling" 7 | ) 8 | 9 | // User represents a Twitter User. 10 | // https://dev.twitter.com/overview/api/users 11 | type User struct { 12 | ContributorsEnabled bool `json:"contributors_enabled"` 13 | CreatedAt string `json:"created_at"` 14 | DefaultProfile bool `json:"default_profile"` 15 | DefaultProfileImage bool `json:"default_profile_image"` 16 | Description string `json:"description"` 17 | Email string `json:"email"` 18 | Entities *UserEntities `json:"entities"` 19 | FavouritesCount int `json:"favourites_count"` 20 | FollowRequestSent bool `json:"follow_request_sent"` 21 | Following bool `json:"following"` 22 | FollowersCount int `json:"followers_count"` 23 | FriendsCount int `json:"friends_count"` 24 | GeoEnabled bool `json:"geo_enabled"` 25 | ID int64 `json:"id"` 26 | IDStr string `json:"id_str"` 27 | IsTranslator bool `json:"is_translator"` 28 | Lang string `json:"lang"` 29 | ListedCount int `json:"listed_count"` 30 | Location string `json:"location"` 31 | Name string `json:"name"` 32 | Notifications bool `json:"notifications"` 33 | ProfileBackgroundColor string `json:"profile_background_color"` 34 | ProfileBackgroundImageURL string `json:"profile_background_image_url"` 35 | ProfileBackgroundImageURLHttps string `json:"profile_background_image_url_https"` 36 | ProfileBackgroundTile bool `json:"profile_background_tile"` 37 | ProfileBannerURL string `json:"profile_banner_url"` 38 | ProfileImageURL string `json:"profile_image_url"` 39 | ProfileImageURLHttps string `json:"profile_image_url_https"` 40 | ProfileLinkColor string `json:"profile_link_color"` 41 | ProfileSidebarBorderColor string `json:"profile_sidebar_border_color"` 42 | ProfileSidebarFillColor string `json:"profile_sidebar_fill_color"` 43 | ProfileTextColor string `json:"profile_text_color"` 44 | ProfileUseBackgroundImage bool `json:"profile_use_background_image"` 45 | Protected bool `json:"protected"` 46 | ScreenName string `json:"screen_name"` 47 | ShowAllInlineMedia bool `json:"show_all_inline_media"` 48 | Status *Tweet `json:"status"` 49 | StatusesCount int `json:"statuses_count"` 50 | Timezone string `json:"time_zone"` 51 | URL string `json:"url"` 52 | UtcOffset int `json:"utc_offset"` 53 | Verified bool `json:"verified"` 54 | WithheldInCountries []string `json:"withheld_in_countries"` 55 | WithholdScope string `json:"withheld_scope"` 56 | } 57 | 58 | // UserService provides methods for accessing Twitter user API endpoints. 59 | type UserService struct { 60 | sling *sling.Sling 61 | } 62 | 63 | // newUserService returns a new UserService. 64 | func newUserService(sling *sling.Sling) *UserService { 65 | return &UserService{ 66 | sling: sling.Path("users/"), 67 | } 68 | } 69 | 70 | // UserShowParams are the parameters for UserService.Show. 71 | type UserShowParams struct { 72 | UserID int64 `url:"user_id,omitempty"` 73 | ScreenName string `url:"screen_name,omitempty"` 74 | IncludeEntities *bool `url:"include_entities,omitempty"` // whether 'status' should include entities 75 | } 76 | 77 | // Show returns the requested User. 78 | // https://dev.twitter.com/rest/reference/get/users/show 79 | func (s *UserService) Show(params *UserShowParams) (*User, *http.Response, error) { 80 | user := new(User) 81 | apiError := new(APIError) 82 | resp, err := s.sling.New().Get("show.json").QueryStruct(params).Receive(user, apiError) 83 | return user, resp, relevantError(err, *apiError) 84 | } 85 | 86 | // UserLookupParams are the parameters for UserService.Lookup. 87 | type UserLookupParams struct { 88 | UserID []int64 `url:"user_id,omitempty,comma"` 89 | ScreenName []string `url:"screen_name,omitempty,comma"` 90 | IncludeEntities *bool `url:"include_entities,omitempty"` // whether 'status' should include entities 91 | } 92 | 93 | // Lookup returns the requested Users as a slice. 94 | // https://dev.twitter.com/rest/reference/get/users/lookup 95 | func (s *UserService) Lookup(params *UserLookupParams) ([]User, *http.Response, error) { 96 | users := new([]User) 97 | apiError := new(APIError) 98 | resp, err := s.sling.New().Get("lookup.json").QueryStruct(params).Receive(users, apiError) 99 | return *users, resp, relevantError(err, *apiError) 100 | } 101 | 102 | // UserSearchParams are the parameters for UserService.Search. 103 | type UserSearchParams struct { 104 | Query string `url:"q,omitempty"` 105 | Page int `url:"page,omitempty"` // 1-based page number 106 | Count int `url:"count,omitempty"` 107 | IncludeEntities *bool `url:"include_entities,omitempty"` // whether 'status' should include entities 108 | } 109 | 110 | // Search queries public user accounts. 111 | // Requires a user auth context. 112 | // https://dev.twitter.com/rest/reference/get/users/search 113 | func (s *UserService) Search(query string, params *UserSearchParams) ([]User, *http.Response, error) { 114 | if params == nil { 115 | params = &UserSearchParams{} 116 | } 117 | params.Query = query 118 | users := new([]User) 119 | apiError := new(APIError) 120 | resp, err := s.sling.New().Get("search.json").QueryStruct(params).Receive(users, apiError) 121 | return *users, resp, relevantError(err, *apiError) 122 | } 123 | -------------------------------------------------------------------------------- /twitter/users_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestUserService_Show(t *testing.T) { 12 | httpClient, mux, server := testServer() 13 | defer server.Close() 14 | 15 | mux.HandleFunc("/1.1/users/show.json", func(w http.ResponseWriter, r *http.Request) { 16 | assertMethod(t, "GET", r) 17 | assertQuery(t, map[string]string{"screen_name": "xkcdComic"}, r) 18 | w.Header().Set("Content-Type", "application/json") 19 | fmt.Fprintf(w, `{"name": "XKCD Comic", "favourites_count": 2}`) 20 | }) 21 | 22 | client := NewClient(httpClient) 23 | user, _, err := client.Users.Show(&UserShowParams{ScreenName: "xkcdComic"}) 24 | expected := &User{Name: "XKCD Comic", FavouritesCount: 2} 25 | assert.Nil(t, err) 26 | assert.Equal(t, expected, user) 27 | } 28 | 29 | func TestUserService_LookupWithIds(t *testing.T) { 30 | httpClient, mux, server := testServer() 31 | defer server.Close() 32 | 33 | mux.HandleFunc("/1.1/users/lookup.json", func(w http.ResponseWriter, r *http.Request) { 34 | assertMethod(t, "GET", r) 35 | assertQuery(t, map[string]string{"user_id": "113419064,623265148"}, r) 36 | w.Header().Set("Content-Type", "application/json") 37 | fmt.Fprintf(w, `[{"screen_name": "golang"}, {"screen_name": "dghubble"}]`) 38 | }) 39 | 40 | client := NewClient(httpClient) 41 | users, _, err := client.Users.Lookup(&UserLookupParams{UserID: []int64{113419064, 623265148}}) 42 | expected := []User{{ScreenName: "golang"}, {ScreenName: "dghubble"}} 43 | assert.Nil(t, err) 44 | assert.Equal(t, expected, users) 45 | } 46 | 47 | func TestUserService_LookupWithScreenNames(t *testing.T) { 48 | httpClient, mux, server := testServer() 49 | defer server.Close() 50 | 51 | mux.HandleFunc("/1.1/users/lookup.json", func(w http.ResponseWriter, r *http.Request) { 52 | assertMethod(t, "GET", r) 53 | assertQuery(t, map[string]string{"screen_name": "foo,bar"}, r) 54 | w.Header().Set("Content-Type", "application/json") 55 | fmt.Fprintf(w, `[{"name": "Foo"}, {"name": "Bar"}]`) 56 | }) 57 | 58 | client := NewClient(httpClient) 59 | users, _, err := client.Users.Lookup(&UserLookupParams{ScreenName: []string{"foo", "bar"}}) 60 | expected := []User{{Name: "Foo"}, {Name: "Bar"}} 61 | assert.Nil(t, err) 62 | assert.Equal(t, expected, users) 63 | } 64 | 65 | func TestUserService_Search(t *testing.T) { 66 | httpClient, mux, server := testServer() 67 | defer server.Close() 68 | 69 | mux.HandleFunc("/1.1/users/search.json", func(w http.ResponseWriter, r *http.Request) { 70 | assertMethod(t, "GET", r) 71 | assertQuery(t, map[string]string{"count": "11", "q": "news"}, r) 72 | w.Header().Set("Content-Type", "application/json") 73 | fmt.Fprintf(w, `[{"name": "BBC"}, {"name": "BBC Breaking News"}]`) 74 | }) 75 | 76 | client := NewClient(httpClient) 77 | users, _, err := client.Users.Search("news", &UserSearchParams{Query: "override me", Count: 11}) 78 | expected := []User{{Name: "BBC"}, {Name: "BBC Breaking News"}} 79 | assert.Nil(t, err) 80 | assert.Equal(t, expected, users) 81 | } 82 | 83 | func TestUserService_SearchHandlesNilParams(t *testing.T) { 84 | httpClient, mux, server := testServer() 85 | defer server.Close() 86 | 87 | mux.HandleFunc("/1.1/users/search.json", func(w http.ResponseWriter, r *http.Request) { 88 | assertQuery(t, map[string]string{"q": "news"}, r) 89 | }) 90 | client := NewClient(httpClient) 91 | _, _, err := client.Users.Search("news", nil) 92 | assert.Nil(t, err) 93 | } 94 | --------------------------------------------------------------------------------