Merge lp:~jamesh/account-polld/twitter-plugin into lp:~phablet-team/account-polld/trunk
- twitter-plugin
- Merge into trunk
Status: | Merged |
---|---|
Merged at revision: | 11 |
Proposed branch: | lp:~jamesh/account-polld/twitter-plugin |
Merge into: | lp:~phablet-team/account-polld/trunk |
Diff against target: |
1461 lines (+1383/-9) 8 files modified
cmd/account-polld/main.go (+3/-2) plugins/facebook/facebook_test.go (+7/-7) plugins/twitter/oauth/README.markdown (+22/-0) plugins/twitter/oauth/examples_test.go (+54/-0) plugins/twitter/oauth/oauth.go (+456/-0) plugins/twitter/oauth/oauth_test.go (+172/-0) plugins/twitter/twitter.go (+214/-0) plugins/twitter/twitter_test.go (+455/-0) |
To merge this branch: | bzr merge lp:~jamesh/account-polld/twitter-plugin |
Related bugs: | |
Related blueprints: |
account-polld push and account notification
(Undefined)
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Sergio Schvezov | Pending | ||
Review via email: mp+227313@code.launchpad.net |
Commit message
Add the Twitter polling plugin.
Description of the change
Add the Twitter plugin, producing notifications for mentions and direct messages. Both of these features work with the permissions provided by the token from the "twitter-microblog" service.
Incremental results are returned using the since_id API option.
OAuth 1.0a signing is done using the external github.
Sergio Schvezov (sergiusens) wrote : | # |
- 14. By James Henstridge
-
Fix up comment, and run through gofmt.
James Henstridge (jamesh) wrote : | # |
I've fixed the comment and run the code through gofmt.
The Twitter web site recommended using 64 bit integers. It didn't say one way or the other about signed/unsigned, and the two Go Twitter libraries linked from https:/
It's hard to tell whether this will be a problem, since (a) the range of 64-bit integers is so large and (b) they'd need to come up with a solution for languages without unsigned types like Java before they got to that point anyway.
Sergio Schvezov (sergiusens) wrote : | # |
Two more things; Lucio told me it would be good to setup the Icon in the
bubble; he said it was ok to use the one provided in the click app for now;
that is located in:
/usr/share/
I will switch to loading than from the push helper once this lands (as the
helper would be part of the click itself).
The other thing is; can we inline/embed the oauth package into this trunk
as part of this merge?
PS: totally unrelated, but I can't comment on this MP.
- 15. By James Henstridge
-
Add icon to Twitter messages.
- 16. By James Henstridge
-
Embed the OAuth 1 library, since we don't have it packaged.
Preview Diff
1 | === modified file 'cmd/account-polld/main.go' |
2 | --- cmd/account-polld/main.go 2014-07-17 19:54:39 +0000 |
3 | +++ cmd/account-polld/main.go 2014-07-23 10:36:01 +0000 |
4 | @@ -28,6 +28,7 @@ |
5 | "launchpad.net/account-polld/plugins" |
6 | "launchpad.net/account-polld/plugins/facebook" |
7 | "launchpad.net/account-polld/plugins/gmail" |
8 | + "launchpad.net/account-polld/plugins/twitter" |
9 | "launchpad.net/go-dbus/v1" |
10 | ) |
11 | |
12 | @@ -91,8 +92,8 @@ |
13 | plugin = facebook.New() |
14 | case SERVICENAME_TWITTER: |
15 | // This is just stubbed until the plugin exists. |
16 | - log.Println("Unhandled account with id", data.AccountId, "for", data.ServiceName) |
17 | - continue L |
18 | + log.Println("Creating account with id", data.AccountId, "for", data.ServiceName) |
19 | + plugin = twitter.New() |
20 | default: |
21 | log.Println("Unhandled account with id", data.AccountId, "for", data.ServiceName) |
22 | continue L |
23 | |
24 | === modified file 'plugins/facebook/facebook_test.go' |
25 | --- plugins/facebook/facebook_test.go 2014-07-17 10:25:18 +0000 |
26 | +++ plugins/facebook/facebook_test.go 2014-07-23 10:36:01 +0000 |
27 | @@ -120,11 +120,11 @@ |
28 | Body: closeWrapper{bytes.NewReader([]byte(notificationsBody))}, |
29 | } |
30 | p := &fbPlugin{} |
31 | - notifications, err := p.parseResponse(resp) |
32 | + messages, err := p.parseResponse(resp) |
33 | c.Assert(err, IsNil) |
34 | - c.Assert(len(notifications), Equals, 2) |
35 | - c.Check(notifications[0].Card.Summary, Equals, "Sender posted on your timeline: \"The message...\"") |
36 | - c.Check(notifications[1].Card.Summary, Equals, "Sender2's birthday was on July 7.") |
37 | + c.Assert(len(messages), Equals, 2) |
38 | + c.Check(messages[0].Notification.Card.Summary, Equals, "Sender posted on your timeline: \"The message...\"") |
39 | + c.Check(messages[1].Notification.Card.Summary, Equals, "Sender2's birthday was on July 7.") |
40 | c.Check(p.lastUpdate, Equals, "2014-07-12T09:51:57+0000") |
41 | } |
42 | |
43 | @@ -134,10 +134,10 @@ |
44 | Body: closeWrapper{bytes.NewReader([]byte(notificationsBody))}, |
45 | } |
46 | p := &fbPlugin{lastUpdate: "2014-07-08T06:17:52+0000"} |
47 | - notifications, err := p.parseResponse(resp) |
48 | + messages, err := p.parseResponse(resp) |
49 | c.Assert(err, IsNil) |
50 | - c.Assert(len(notifications), Equals, 1) |
51 | - c.Check(notifications[0].Card.Summary, Equals, "Sender posted on your timeline: \"The message...\"") |
52 | + c.Assert(len(messages), Equals, 1) |
53 | + c.Check(messages[0].Notification.Card.Summary, Equals, "Sender posted on your timeline: \"The message...\"") |
54 | c.Check(p.lastUpdate, Equals, "2014-07-12T09:51:57+0000") |
55 | } |
56 | |
57 | |
58 | === added directory 'plugins/twitter' |
59 | === added directory 'plugins/twitter/oauth' |
60 | === added file 'plugins/twitter/oauth/README.markdown' |
61 | --- plugins/twitter/oauth/README.markdown 1970-01-01 00:00:00 +0000 |
62 | +++ plugins/twitter/oauth/README.markdown 2014-07-23 10:36:01 +0000 |
63 | @@ -0,0 +1,22 @@ |
64 | +# Go-OAuth |
65 | + |
66 | +Go-OAuth is a [Go](http://golang.org/) client for the OAuth 1.0, OAuth 1.0a and RFC 5849. |
67 | + |
68 | +## Installation |
69 | + |
70 | +Use the [go tool](http://weekly.golang.org/cmd/go/) to install Go-OAuth: |
71 | + |
72 | + go get github.com/garyburd/go-oauth/oauth |
73 | + |
74 | +##License |
75 | + |
76 | +Go-OAuth is available under the [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.html). |
77 | + |
78 | +## Documentation |
79 | + |
80 | +- [Reference](http://godoc.org/github.com/garyburd/go-oauth/oauth) |
81 | +- [Dropbox Example](http://github.com/garyburd/go-oauth/tree/master/examples/dropbox) |
82 | +- [Netflix Example](http://github.com/garyburd/go-oauth/tree/master/examples/netflix) |
83 | +- [SmugMug Example](https://github.com/garyburd/go-oauth/tree/master/examples/smugmug) |
84 | +- [Twitter Example](http://github.com/garyburd/go-oauth/tree/master/examples/twitter) |
85 | +- [Twitter Example on App Engine](http://github.com/garyburd/go-oauth/tree/master/examples/appengine) |
86 | |
87 | === added file 'plugins/twitter/oauth/examples_test.go' |
88 | --- plugins/twitter/oauth/examples_test.go 1970-01-01 00:00:00 +0000 |
89 | +++ plugins/twitter/oauth/examples_test.go 2014-07-23 10:36:01 +0000 |
90 | @@ -0,0 +1,54 @@ |
91 | +// Copyright 2013 Gary Burd |
92 | +// |
93 | +// Licensed under the Apache License, Version 2.0 (the "License"): you may |
94 | +// not use this file except in compliance with the License. You may obtain |
95 | +// a copy of the License at |
96 | +// |
97 | +// http://www.apache.org/licenses/LICENSE-2.0 |
98 | +// |
99 | +// Unless required by applicable law or agreed to in writing, software |
100 | +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
101 | +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
102 | +// License for the specific language governing permissions and limitations |
103 | +// under the License. |
104 | + |
105 | +package oauth_test |
106 | + |
107 | +import ( |
108 | + "github.com/garyburd/go-oauth/oauth" |
109 | + "net/http" |
110 | + "net/url" |
111 | + "strings" |
112 | +) |
113 | + |
114 | +// This example shows how to sign a request when the URL Opaque field is used. |
115 | +// See the note at http://golang.org/pkg/net/url/#URL for information on the |
116 | +// use of the URL Opaque field. |
117 | +func ExampleClient_AuthorizationHeader(client *oauth.Client, credentials *oauth.Credentials) error { |
118 | + form := url.Values{"maxResults": {"100"}} |
119 | + |
120 | + // The last element of path contains a "/". |
121 | + path := "/document/encoding%2gizp" |
122 | + |
123 | + // Create the request with the temporary path "/". |
124 | + req, err := http.NewRequest("GET", "http://api.example.com/", strings.NewReader(form.Encode())) |
125 | + if err != nil { |
126 | + return err |
127 | + } |
128 | + |
129 | + // Overwrite the temporary path with the actual request path. |
130 | + req.URL.Opaque = path |
131 | + |
132 | + // Sign the request. |
133 | + req.Header.Set("Authorization", client.AuthorizationHeader(credentials, "GET", req.URL, form)) |
134 | + |
135 | + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
136 | + |
137 | + resp, err := http.DefaultClient.Do(req) |
138 | + if err != nil { |
139 | + return err |
140 | + } |
141 | + defer resp.Body.Close() |
142 | + // process the response |
143 | + return nil |
144 | +} |
145 | |
146 | === added file 'plugins/twitter/oauth/oauth.go' |
147 | --- plugins/twitter/oauth/oauth.go 1970-01-01 00:00:00 +0000 |
148 | +++ plugins/twitter/oauth/oauth.go 2014-07-23 10:36:01 +0000 |
149 | @@ -0,0 +1,456 @@ |
150 | +// Copyright 2010 Gary Burd |
151 | +// |
152 | +// Licensed under the Apache License, Version 2.0 (the "License"): you may |
153 | +// not use this file except in compliance with the License. You may obtain |
154 | +// a copy of the License at |
155 | +// |
156 | +// http://www.apache.org/licenses/LICENSE-2.0 |
157 | +// |
158 | +// Unless required by applicable law or agreed to in writing, software |
159 | +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
160 | +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
161 | +// License for the specific language governing permissions and limitations |
162 | +// under the License. |
163 | + |
164 | +// Package oauth is consumer interface for OAuth 1.0, OAuth 1.0a and RFC 5849. |
165 | +// |
166 | +// Redirection-based Authorization |
167 | +// |
168 | +// This section outlines how to use the oauth package in redirection-based |
169 | +// authorization (http://tools.ietf.org/html/rfc5849#section-2). |
170 | +// |
171 | +// Step 1: Create a Client using credentials and URIs provided by the server. |
172 | +// The Client can be initialized once at application startup and stored in a |
173 | +// package-level variable. |
174 | +// |
175 | +// Step 2: Request temporary credentials using the Client |
176 | +// RequestTemporaryCredentials method. The callbackURL parameter is the URL of |
177 | +// the callback handler in step 4. Save the returned credential secret so that |
178 | +// it can be later found using credential token as a key. The secret can be |
179 | +// stored in a database keyed by the token. Another option is to store the |
180 | +// token and secret in session storage or a cookie. |
181 | +// |
182 | +// Step 3: Redirect the user to URL returned from AuthorizationURL method. The |
183 | +// AuthorizationURL method uses the temporary credentials from step 2 and other |
184 | +// parameters as specified by the server. |
185 | +// |
186 | +// Step 4: The server redirects back to the callback URL specified in step 2 |
187 | +// with the temporary token and a verifier. Use the temporary token to find the |
188 | +// temporary secret saved in step 2. Using the temporary token, temporary |
189 | +// secret and verifier, request token credentials using the client RequestToken |
190 | +// method. Save the returned credentials for later use in the application. |
191 | +// |
192 | +// Signing Requests |
193 | +// |
194 | +// The Client type has two low-level methods for signing requests, SignForm and |
195 | +// AuthorizationHeader. |
196 | +// |
197 | +// The SignForm method adds an OAuth signature to a form. The application makes |
198 | +// an authenticated request by encoding the modified form to the query string |
199 | +// or request body. |
200 | +// |
201 | +// The AuthorizationHeader method returns an Authorization header value with |
202 | +// the OAuth signature. The application makes an authenticated request by |
203 | +// adding the Authorization header to the request. The AuthorizationHeader |
204 | +// method is the only way to correctly sign a request if the application sets |
205 | +// the URL Opaque field when making a request. |
206 | +// |
207 | +// The Get and Post methods sign and invoke a request using the supplied |
208 | +// net/http Client. These methods are easy to use, but not as flexible as |
209 | +// constructing a request using one of the low-level methods. |
210 | +package oauth |
211 | + |
212 | +import ( |
213 | + "bytes" |
214 | + "crypto/hmac" |
215 | + "crypto/rand" |
216 | + "crypto/sha1" |
217 | + "encoding/base64" |
218 | + "encoding/binary" |
219 | + "errors" |
220 | + "fmt" |
221 | + "io" |
222 | + "io/ioutil" |
223 | + "net/http" |
224 | + "net/url" |
225 | + "sort" |
226 | + "strconv" |
227 | + "strings" |
228 | + "sync" |
229 | + "time" |
230 | +) |
231 | + |
232 | +// noscape[b] is true if b should not be escaped per section 3.6 of the RFC. |
233 | +var noEscape = [256]bool{ |
234 | + 'A': true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, |
235 | + 'a': true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, |
236 | + '0': true, true, true, true, true, true, true, true, true, true, |
237 | + '-': true, |
238 | + '.': true, |
239 | + '_': true, |
240 | + '~': true, |
241 | +} |
242 | + |
243 | +// encode encodes string per section 3.6 of the RFC. If double is true, then |
244 | +// the encoding is applied twice. |
245 | +func encode(s string, double bool) []byte { |
246 | + // Compute size of result. |
247 | + m := 3 |
248 | + if double { |
249 | + m = 5 |
250 | + } |
251 | + n := 0 |
252 | + for i := 0; i < len(s); i++ { |
253 | + if noEscape[s[i]] { |
254 | + n += 1 |
255 | + } else { |
256 | + n += m |
257 | + } |
258 | + } |
259 | + |
260 | + p := make([]byte, n) |
261 | + |
262 | + // Encode it. |
263 | + j := 0 |
264 | + for i := 0; i < len(s); i++ { |
265 | + b := s[i] |
266 | + if noEscape[b] { |
267 | + p[j] = b |
268 | + j += 1 |
269 | + } else if double { |
270 | + p[j] = '%' |
271 | + p[j+1] = '2' |
272 | + p[j+2] = '5' |
273 | + p[j+3] = "0123456789ABCDEF"[b>>4] |
274 | + p[j+4] = "0123456789ABCDEF"[b&15] |
275 | + j += 5 |
276 | + } else { |
277 | + p[j] = '%' |
278 | + p[j+1] = "0123456789ABCDEF"[b>>4] |
279 | + p[j+2] = "0123456789ABCDEF"[b&15] |
280 | + j += 3 |
281 | + } |
282 | + } |
283 | + return p |
284 | +} |
285 | + |
286 | +type keyValue struct{ key, value []byte } |
287 | + |
288 | +type byKeyValue []keyValue |
289 | + |
290 | +func (p byKeyValue) Len() int { return len(p) } |
291 | +func (p byKeyValue) Swap(i, j int) { p[i], p[j] = p[j], p[i] } |
292 | +func (p byKeyValue) Less(i, j int) bool { |
293 | + sgn := bytes.Compare(p[i].key, p[j].key) |
294 | + if sgn == 0 { |
295 | + sgn = bytes.Compare(p[i].value, p[j].value) |
296 | + } |
297 | + return sgn < 0 |
298 | +} |
299 | + |
300 | +func (p byKeyValue) appendValues(values url.Values) byKeyValue { |
301 | + for k, vs := range values { |
302 | + k := encode(k, true) |
303 | + for _, v := range vs { |
304 | + v := encode(v, true) |
305 | + p = append(p, keyValue{k, v}) |
306 | + } |
307 | + } |
308 | + return p |
309 | +} |
310 | + |
311 | +// writeBaseString writes method, url, and params to w using the OAuth signature |
312 | +// base string computation described in section 3.4.1 of the RFC. |
313 | +func writeBaseString(w io.Writer, method string, u *url.URL, form url.Values, oauthParams map[string]string) { |
314 | + // Method |
315 | + w.Write(encode(strings.ToUpper(method), false)) |
316 | + w.Write([]byte{'&'}) |
317 | + |
318 | + // URL |
319 | + scheme := strings.ToLower(u.Scheme) |
320 | + host := strings.ToLower(u.Host) |
321 | + |
322 | + uNoQuery := *u |
323 | + uNoQuery.RawQuery = "" |
324 | + path := uNoQuery.RequestURI() |
325 | + |
326 | + switch { |
327 | + case scheme == "http" && strings.HasSuffix(host, ":80"): |
328 | + host = host[:len(host)-len(":80")] |
329 | + case scheme == "https" && strings.HasSuffix(host, ":443"): |
330 | + host = host[:len(host)-len(":443")] |
331 | + } |
332 | + |
333 | + w.Write(encode(scheme, false)) |
334 | + w.Write(encode("://", false)) |
335 | + w.Write(encode(host, false)) |
336 | + w.Write(encode(path, false)) |
337 | + w.Write([]byte{'&'}) |
338 | + |
339 | + // Create sorted slice of encoded parameters. Parameter keys and values are |
340 | + // double encoded in a single step. This is safe because double encoding |
341 | + // does not change the sort order. |
342 | + queryParams := u.Query() |
343 | + p := make(byKeyValue, 0, len(form)+len(queryParams)+len(oauthParams)) |
344 | + p = p.appendValues(form) |
345 | + p = p.appendValues(queryParams) |
346 | + for k, v := range oauthParams { |
347 | + p = append(p, keyValue{encode(k, true), encode(v, true)}) |
348 | + } |
349 | + sort.Sort(p) |
350 | + |
351 | + // Write the parameters. |
352 | + encodedAmp := encode("&", false) |
353 | + encodedEqual := encode("=", false) |
354 | + sep := false |
355 | + for _, kv := range p { |
356 | + if sep { |
357 | + w.Write(encodedAmp) |
358 | + } else { |
359 | + sep = true |
360 | + } |
361 | + w.Write(kv.key) |
362 | + w.Write(encodedEqual) |
363 | + w.Write(kv.value) |
364 | + } |
365 | +} |
366 | + |
367 | +var ( |
368 | + nonceLock sync.Mutex |
369 | + nonceCounter uint64 |
370 | +) |
371 | + |
372 | +// nonce returns a unique string. |
373 | +func nonce() string { |
374 | + nonceLock.Lock() |
375 | + defer nonceLock.Unlock() |
376 | + if nonceCounter == 0 { |
377 | + binary.Read(rand.Reader, binary.BigEndian, &nonceCounter) |
378 | + } |
379 | + result := strconv.FormatUint(nonceCounter, 16) |
380 | + nonceCounter += 1 |
381 | + return result |
382 | +} |
383 | + |
384 | +// oauthParams returns the OAuth request parameters for the given credentials, |
385 | +// method, URL and application params. See |
386 | +// http://tools.ietf.org/html/rfc5849#section-3.4 for more information about |
387 | +// signatures. |
388 | +func oauthParams(clientCredentials *Credentials, credentials *Credentials, method string, u *url.URL, form url.Values) map[string]string { |
389 | + oauthParams := map[string]string{ |
390 | + "oauth_consumer_key": clientCredentials.Token, |
391 | + "oauth_signature_method": "HMAC-SHA1", |
392 | + "oauth_timestamp": strconv.FormatInt(time.Now().Unix(), 10), |
393 | + "oauth_version": "1.0", |
394 | + "oauth_nonce": nonce(), |
395 | + } |
396 | + if credentials != nil { |
397 | + oauthParams["oauth_token"] = credentials.Token |
398 | + } |
399 | + if testingNonce != "" { |
400 | + oauthParams["oauth_nonce"] = testingNonce |
401 | + } |
402 | + if testingTimestamp != "" { |
403 | + oauthParams["oauth_timestamp"] = testingTimestamp |
404 | + } |
405 | + |
406 | + var key bytes.Buffer |
407 | + key.Write(encode(clientCredentials.Secret, false)) |
408 | + key.WriteByte('&') |
409 | + if credentials != nil { |
410 | + key.Write(encode(credentials.Secret, false)) |
411 | + } |
412 | + |
413 | + h := hmac.New(sha1.New, key.Bytes()) |
414 | + writeBaseString(h, method, u, form, oauthParams) |
415 | + sum := h.Sum(nil) |
416 | + |
417 | + encodedSum := make([]byte, base64.StdEncoding.EncodedLen(len(sum))) |
418 | + base64.StdEncoding.Encode(encodedSum, sum) |
419 | + |
420 | + oauthParams["oauth_signature"] = string(encodedSum) |
421 | + return oauthParams |
422 | +} |
423 | + |
424 | +// Client represents an OAuth client. |
425 | +type Client struct { |
426 | + Credentials Credentials |
427 | + TemporaryCredentialRequestURI string // Also known as request token URL. |
428 | + ResourceOwnerAuthorizationURI string // Also known as authorization URL. |
429 | + TokenRequestURI string // Also known as access token URL. |
430 | +} |
431 | + |
432 | +// Credentials represents client, temporary and token credentials. |
433 | +type Credentials struct { |
434 | + Token string // Also known as consumer key or access token. |
435 | + Secret string // Also known as consumer secret or access token secret. |
436 | +} |
437 | + |
438 | +var ( |
439 | + testingTimestamp string |
440 | + testingNonce string |
441 | +) |
442 | + |
443 | +// SignForm adds an OAuth signature to form. The urlStr argument must not |
444 | +// include a query string. |
445 | +// |
446 | +// See http://tools.ietf.org/html/rfc5849#section-3.5.2 for |
447 | +// information about transmitting OAuth parameters in a request body and |
448 | +// http://tools.ietf.org/html/rfc5849#section-3.5.2 for information about |
449 | +// transmitting OAuth parameters in a query string. |
450 | +func (c *Client) SignForm(credentials *Credentials, method, urlStr string, form url.Values) error { |
451 | + u, err := url.Parse(urlStr) |
452 | + switch { |
453 | + case err != nil: |
454 | + return err |
455 | + case u.RawQuery != "": |
456 | + return errors.New("oauth: urlStr argument to SignForm must not include a query string") |
457 | + } |
458 | + for k, v := range oauthParams(&c.Credentials, credentials, method, u, form) { |
459 | + form.Set(k, v) |
460 | + } |
461 | + return nil |
462 | +} |
463 | + |
464 | +// SignParam is deprecated. Use SignForm instead. |
465 | +func (c *Client) SignParam(credentials *Credentials, method, urlStr string, params url.Values) { |
466 | + u, _ := url.Parse(urlStr) |
467 | + u.RawQuery = "" |
468 | + for k, v := range oauthParams(&c.Credentials, credentials, method, u, params) { |
469 | + params.Set(k, v) |
470 | + } |
471 | +} |
472 | + |
473 | +// AuthorizationHeader returns the HTTP authorization header value for given |
474 | +// method, URL and parameters. |
475 | +// |
476 | +// See http://tools.ietf.org/html/rfc5849#section-3.5.1 for information about |
477 | +// transmitting OAuth parameters in an HTTP request header. |
478 | +func (c *Client) AuthorizationHeader(credentials *Credentials, method string, u *url.URL, params url.Values) string { |
479 | + p := oauthParams(&c.Credentials, credentials, method, u, params) |
480 | + var buf bytes.Buffer |
481 | + buf.WriteString(`OAuth oauth_consumer_key="`) |
482 | + buf.Write(encode(p["oauth_consumer_key"], false)) |
483 | + buf.WriteString(`", oauth_nonce="`) |
484 | + buf.Write(encode(p["oauth_nonce"], false)) |
485 | + buf.WriteString(`", oauth_signature="`) |
486 | + buf.Write(encode(p["oauth_signature"], false)) |
487 | + buf.WriteString(`", oauth_signature_method="HMAC-SHA1", oauth_timestamp="`) |
488 | + buf.Write(encode(p["oauth_timestamp"], false)) |
489 | + if t, ok := p["oauth_token"]; ok { |
490 | + buf.WriteString(`", oauth_token="`) |
491 | + buf.Write(encode(t, false)) |
492 | + } |
493 | + buf.WriteString(`", oauth_version="1.0"`) |
494 | + return buf.String() |
495 | +} |
496 | + |
497 | +// Get issues a GET to the specified URL with form added as a query string. |
498 | +func (c *Client) Get(client *http.Client, credentials *Credentials, urlStr string, form url.Values) (*http.Response, error) { |
499 | + req, err := http.NewRequest("GET", urlStr, nil) |
500 | + if err != nil { |
501 | + return nil, err |
502 | + } |
503 | + if req.URL.RawQuery != "" { |
504 | + return nil, errors.New("oauth: url must not contain a query string") |
505 | + } |
506 | + req.Header.Set("Authorization", c.AuthorizationHeader(credentials, "GET", req.URL, form)) |
507 | + req.URL.RawQuery = form.Encode() |
508 | + return client.Do(req) |
509 | +} |
510 | + |
511 | +func (c *Client) do(client *http.Client, method string, credentials *Credentials, urlStr string, form url.Values) (*http.Response, error) { |
512 | + req, err := http.NewRequest(method, urlStr, strings.NewReader(form.Encode())) |
513 | + if err != nil { |
514 | + return nil, err |
515 | + } |
516 | + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
517 | + req.Header.Set("Authorization", c.AuthorizationHeader(credentials, method, req.URL, form)) |
518 | + return client.Do(req) |
519 | +} |
520 | + |
521 | +// Post issues a POST with the specified form. |
522 | +func (c *Client) Post(client *http.Client, credentials *Credentials, urlStr string, form url.Values) (*http.Response, error) { |
523 | + return c.do(client, "POST", credentials, urlStr, form) |
524 | +} |
525 | + |
526 | +// Delete issues a DELETE with the specified form. |
527 | +func (c *Client) Delete(client *http.Client, credentials *Credentials, urlStr string, form url.Values) (*http.Response, error) { |
528 | + return c.do(client, "DELETE", credentials, urlStr, form) |
529 | +} |
530 | + |
531 | +// Put issues a PUT with the specified form. |
532 | +func (c *Client) Put(client *http.Client, credentials *Credentials, urlStr string, form url.Values) (*http.Response, error) { |
533 | + return c.do(client, "PUT", credentials, urlStr, form) |
534 | +} |
535 | + |
536 | +func (c *Client) request(client *http.Client, credentials *Credentials, urlStr string, params url.Values) (*Credentials, url.Values, error) { |
537 | + c.SignParam(credentials, "POST", urlStr, params) |
538 | + resp, err := client.PostForm(urlStr, params) |
539 | + if err != nil { |
540 | + return nil, nil, err |
541 | + } |
542 | + p, err := ioutil.ReadAll(resp.Body) |
543 | + resp.Body.Close() |
544 | + if err != nil { |
545 | + return nil, nil, err |
546 | + } |
547 | + if resp.StatusCode != 200 && resp.StatusCode != 201 { |
548 | + return nil, nil, fmt.Errorf("OAuth server status %d, %s", resp.StatusCode, string(p)) |
549 | + } |
550 | + m, err := url.ParseQuery(string(p)) |
551 | + if err != nil { |
552 | + return nil, nil, err |
553 | + } |
554 | + tokens := m["oauth_token"] |
555 | + if len(tokens) == 0 || tokens[0] == "" { |
556 | + return nil, nil, errors.New("oauth: token missing from server result") |
557 | + } |
558 | + secrets := m["oauth_token_secret"] |
559 | + if len(secrets) == 0 { // allow "" as a valid secret. |
560 | + return nil, nil, errors.New("oauth: secret missing from server result") |
561 | + } |
562 | + return &Credentials{Token: tokens[0], Secret: secrets[0]}, m, nil |
563 | +} |
564 | + |
565 | +// RequestTemporaryCredentials requests temporary credentials from the server. |
566 | +// See http://tools.ietf.org/html/rfc5849#section-2.1 for information about |
567 | +// temporary credentials. |
568 | +func (c *Client) RequestTemporaryCredentials(client *http.Client, callbackURL string, additionalParams url.Values) (*Credentials, error) { |
569 | + params := make(url.Values) |
570 | + for k, vs := range additionalParams { |
571 | + params[k] = vs |
572 | + } |
573 | + if callbackURL != "" { |
574 | + params.Set("oauth_callback", callbackURL) |
575 | + } |
576 | + credentials, _, err := c.request(client, nil, c.TemporaryCredentialRequestURI, params) |
577 | + return credentials, err |
578 | +} |
579 | + |
580 | +// RequestToken requests token credentials from the server. See |
581 | +// http://tools.ietf.org/html/rfc5849#section-2.3 for information about token |
582 | +// credentials. |
583 | +func (c *Client) RequestToken(client *http.Client, temporaryCredentials *Credentials, verifier string) (*Credentials, url.Values, error) { |
584 | + params := make(url.Values) |
585 | + if verifier != "" { |
586 | + params.Set("oauth_verifier", verifier) |
587 | + } |
588 | + credentials, vals, err := c.request(client, temporaryCredentials, c.TokenRequestURI, params) |
589 | + if err != nil { |
590 | + return nil, nil, err |
591 | + } |
592 | + return credentials, vals, nil |
593 | +} |
594 | + |
595 | +// AuthorizationURL returns the URL for resource owner authorization. See |
596 | +// http://tools.ietf.org/html/rfc5849#section-2.2 for information about |
597 | +// resource owner authorization. |
598 | +func (c *Client) AuthorizationURL(temporaryCredentials *Credentials, additionalParams url.Values) string { |
599 | + params := make(url.Values) |
600 | + for k, vs := range additionalParams { |
601 | + params[k] = vs |
602 | + } |
603 | + params.Set("oauth_token", temporaryCredentials.Token) |
604 | + return c.ResourceOwnerAuthorizationURI + "?" + params.Encode() |
605 | +} |
606 | |
607 | === added file 'plugins/twitter/oauth/oauth_test.go' |
608 | --- plugins/twitter/oauth/oauth_test.go 1970-01-01 00:00:00 +0000 |
609 | +++ plugins/twitter/oauth/oauth_test.go 2014-07-23 10:36:01 +0000 |
610 | @@ -0,0 +1,172 @@ |
611 | +// Copyright 2010 Gary Burd |
612 | +// |
613 | +// Licensed under the Apache License, Version 2.0 (the "License"): you may |
614 | +// not use this file except in compliance with the License. You may obtain |
615 | +// a copy of the License at |
616 | +// |
617 | +// http://www.apache.org/licenses/LICENSE-2.0 |
618 | +// |
619 | +// Unless required by applicable law or agreed to in writing, software |
620 | +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
621 | +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
622 | +// License for the specific language governing permissions and limitations |
623 | +// under the License. |
624 | + |
625 | +package oauth |
626 | + |
627 | +import ( |
628 | + "bytes" |
629 | + "net/url" |
630 | + "testing" |
631 | +) |
632 | + |
633 | +func parseURL(urlStr string) *url.URL { |
634 | + u, _ := url.Parse(urlStr) |
635 | + return u |
636 | +} |
637 | + |
638 | +var oauthTests = []struct { |
639 | + method string |
640 | + url *url.URL |
641 | + appParams url.Values |
642 | + nonce string |
643 | + timestamp string |
644 | + |
645 | + clientCredentials Credentials |
646 | + credentials Credentials |
647 | + |
648 | + base string |
649 | + header string |
650 | +}{ |
651 | + { |
652 | + // Simple example from Twitter OAuth tool |
653 | + "GET", |
654 | + parseURL("https://api.twitter.com/1/"), |
655 | + url.Values{"page": {"10"}}, |
656 | + "8067e8abc6bdca2006818132445c8f4c", |
657 | + "1355795903", |
658 | + Credentials{"kMViZR2MHk2mM7hUNVw9A", "56Fgl58yOfqXOhHXX0ybvOmSnPQFvR2miYmm30A"}, |
659 | + Credentials{"10212-JJ3Zc1A49qSMgdcAO2GMOpW9l7A348ESmhjmOBOU", "yF75mvq4LZMHj9O0DXwoC3ZxUnN1ptvieThYuOAYM"}, |
660 | + `GET&https%3A%2F%2Fapi.twitter.com%2F1%2F&oauth_consumer_key%3DkMViZR2MHk2mM7hUNVw9A%26oauth_nonce%3D8067e8abc6bdca2006818132445c8f4c%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1355795903%26oauth_token%3D10212-JJ3Zc1A49qSMgdcAO2GMOpW9l7A348ESmhjmOBOU%26oauth_version%3D1.0%26page%3D10`, |
661 | + `OAuth oauth_consumer_key="kMViZR2MHk2mM7hUNVw9A", oauth_nonce="8067e8abc6bdca2006818132445c8f4c", oauth_signature="o5cx1ggJrY9ognZuVVeUwglKV8U%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1355795903", oauth_token="10212-JJ3Zc1A49qSMgdcAO2GMOpW9l7A348ESmhjmOBOU", oauth_version="1.0"`, |
662 | + }, |
663 | + { |
664 | + // Test case and port insensitivity. |
665 | + "GeT", |
666 | + parseURL("https://apI.twItter.com:443/1/"), |
667 | + url.Values{"page": {"10"}}, |
668 | + "8067e8abc6bdca2006818132445c8f4c", |
669 | + "1355795903", |
670 | + Credentials{"kMViZR2MHk2mM7hUNVw9A", "56Fgl58yOfqXOhHXX0ybvOmSnPQFvR2miYmm30A"}, |
671 | + Credentials{"10212-JJ3Zc1A49qSMgdcAO2GMOpW9l7A348ESmhjmOBOU", "yF75mvq4LZMHj9O0DXwoC3ZxUnN1ptvieThYuOAYM"}, |
672 | + `GET&https%3A%2F%2Fapi.twitter.com%2F1%2F&oauth_consumer_key%3DkMViZR2MHk2mM7hUNVw9A%26oauth_nonce%3D8067e8abc6bdca2006818132445c8f4c%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1355795903%26oauth_token%3D10212-JJ3Zc1A49qSMgdcAO2GMOpW9l7A348ESmhjmOBOU%26oauth_version%3D1.0%26page%3D10`, |
673 | + `OAuth oauth_consumer_key="kMViZR2MHk2mM7hUNVw9A", oauth_nonce="8067e8abc6bdca2006818132445c8f4c", oauth_signature="o5cx1ggJrY9ognZuVVeUwglKV8U%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1355795903", oauth_token="10212-JJ3Zc1A49qSMgdcAO2GMOpW9l7A348ESmhjmOBOU", oauth_version="1.0"`, |
674 | + }, |
675 | + { |
676 | + // Example generated using the Netflix OAuth tool. |
677 | + "GET", |
678 | + parseURL("http://api-public.netflix.com/catalog/titles"), |
679 | + url.Values{"term": {"Dark Knight"}, "count": {"2"}}, |
680 | + "1234", |
681 | + "1355850443", |
682 | + Credentials{"apiKey001", "sharedSecret002"}, |
683 | + Credentials{"accessToken003", "accessSecret004"}, |
684 | + `GET&http%3A%2F%2Fapi-public.netflix.com%2Fcatalog%2Ftitles&count%3D2%26oauth_consumer_key%3DapiKey001%26oauth_nonce%3D1234%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1355850443%26oauth_token%3DaccessToken003%26oauth_version%3D1.0%26term%3DDark%2520Knight`, |
685 | + `OAuth oauth_consumer_key="apiKey001", oauth_nonce="1234", oauth_signature="0JAoaqt6oz6TJx8N%2B06XmhPjcOs%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1355850443", oauth_token="accessToken003", oauth_version="1.0"`, |
686 | + }, |
687 | + { |
688 | + // Test special characters in form values. |
689 | + "GET", |
690 | + parseURL("http://PHOTOS.example.net:8001/Photos"), |
691 | + url.Values{"photo size": {"300%"}, "title": {"Back of $100 Dollars Bill"}}, |
692 | + "kllo~9940~pd9333jh", |
693 | + "1191242096", |
694 | + Credentials{"dpf43f3++p+#2l4k3l03", "secret01"}, |
695 | + Credentials{"nnch734d(0)0sl2jdk", "secret02"}, |
696 | + "GET&http%3A%2F%2Fphotos.example.net%3A8001%2FPhotos&oauth_consumer_key%3Ddpf43f3%252B%252Bp%252B%25232l4k3l03%26oauth_nonce%3Dkllo~9940~pd9333jh%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1191242096%26oauth_token%3Dnnch734d%25280%25290sl2jdk%26oauth_version%3D1.0%26photo%2520size%3D300%2525%26title%3DBack%2520of%2520%2524100%2520Dollars%2520Bill", |
697 | + `OAuth oauth_consumer_key="dpf43f3%2B%2Bp%2B%232l4k3l03", oauth_nonce="kllo~9940~pd9333jh", oauth_signature="n1UAoQy2PoIYizZUiWvkdCxM3P0%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1191242096", oauth_token="nnch734d%280%290sl2jdk", oauth_version="1.0"`, |
698 | + }, |
699 | + { |
700 | + // Test special characters in path, multiple values for same key in form. |
701 | + "GET", |
702 | + parseURL("http://EXAMPLE.COM:80/Space%20Craft"), |
703 | + url.Values{"name": {"value", "value"}}, |
704 | + "Ix4U1Ei3RFL", |
705 | + "1327384901", |
706 | + Credentials{"abcd", "efgh"}, |
707 | + Credentials{"ijkl", "mnop"}, |
708 | + "GET&http%3A%2F%2Fexample.com%2FSpace%2520Craft&name%3Dvalue%26name%3Dvalue%26oauth_consumer_key%3Dabcd%26oauth_nonce%3DIx4U1Ei3RFL%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1327384901%26oauth_token%3Dijkl%26oauth_version%3D1.0", |
709 | + `OAuth oauth_consumer_key="abcd", oauth_nonce="Ix4U1Ei3RFL", oauth_signature="TZZ5u7qQorLnmKs%2Biqunb8gqkh4%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1327384901", oauth_token="ijkl", oauth_version="1.0"`, |
710 | + }, |
711 | + { |
712 | + // Test with query string in URL. |
713 | + "GET", |
714 | + parseURL("http://EXAMPLE.COM:80/Space%20Craft?name=value"), |
715 | + url.Values{"name": {"value"}}, |
716 | + "Ix4U1Ei3RFL", |
717 | + "1327384901", |
718 | + Credentials{"abcd", "efgh"}, |
719 | + Credentials{"ijkl", "mnop"}, |
720 | + "GET&http%3A%2F%2Fexample.com%2FSpace%2520Craft&name%3Dvalue%26name%3Dvalue%26oauth_consumer_key%3Dabcd%26oauth_nonce%3DIx4U1Ei3RFL%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1327384901%26oauth_token%3Dijkl%26oauth_version%3D1.0", |
721 | + `OAuth oauth_consumer_key="abcd", oauth_nonce="Ix4U1Ei3RFL", oauth_signature="TZZ5u7qQorLnmKs%2Biqunb8gqkh4%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1327384901", oauth_token="ijkl", oauth_version="1.0"`, |
722 | + }, |
723 | + { |
724 | + // Test "/" in form value. |
725 | + "POST", |
726 | + parseURL("https://stream.twitter.com/1.1/statuses/filter.json"), |
727 | + url.Values{"track": {"example.com/abcd"}}, |
728 | + "bf2cb6d611e59f99103238fc9a3bb8d8", |
729 | + "1362434376", |
730 | + Credentials{"consumer_key", "consumer_secret"}, |
731 | + Credentials{"token", "secret"}, |
732 | + "POST&https%3A%2F%2Fstream.twitter.com%2F1.1%2Fstatuses%2Ffilter.json&oauth_consumer_key%3Dconsumer_key%26oauth_nonce%3Dbf2cb6d611e59f99103238fc9a3bb8d8%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1362434376%26oauth_token%3Dtoken%26oauth_version%3D1.0%26track%3Dexample.com%252Fabcd", |
733 | + `OAuth oauth_consumer_key="consumer_key", oauth_nonce="bf2cb6d611e59f99103238fc9a3bb8d8", oauth_signature="LcxylEOnNdgoKSJi7jX07mxcvfM%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1362434376", oauth_token="token", oauth_version="1.0"`, |
734 | + }, |
735 | + { |
736 | + // Test "/" in query string |
737 | + "POST", |
738 | + parseURL("https://stream.twitter.com/1.1/statuses/filter.json?track=example.com/query"), |
739 | + url.Values{}, |
740 | + "884275759fbab914654b50ae643c563a", |
741 | + "1362435218", |
742 | + Credentials{"consumer_key", "consumer_secret"}, |
743 | + Credentials{"token", "secret"}, |
744 | + "POST&https%3A%2F%2Fstream.twitter.com%2F1.1%2Fstatuses%2Ffilter.json&oauth_consumer_key%3Dconsumer_key%26oauth_nonce%3D884275759fbab914654b50ae643c563a%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1362435218%26oauth_token%3Dtoken%26oauth_version%3D1.0%26track%3Dexample.com%252Fquery", |
745 | + `OAuth oauth_consumer_key="consumer_key", oauth_nonce="884275759fbab914654b50ae643c563a", oauth_signature="OAldqvRrKDXRGZ9BqSi2CqeVH0g%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1362435218", oauth_token="token", oauth_version="1.0"`, |
746 | + }, |
747 | +} |
748 | + |
749 | +func TestBaseString(t *testing.T) { |
750 | + for _, ot := range oauthTests { |
751 | + oauthParams := map[string]string{ |
752 | + "oauth_consumer_key": ot.clientCredentials.Token, |
753 | + "oauth_nonce": ot.nonce, |
754 | + "oauth_timestamp": ot.timestamp, |
755 | + "oauth_token": ot.credentials.Token, |
756 | + "oauth_signature_method": "HMAC-SHA1", |
757 | + "oauth_version": "1.0", |
758 | + } |
759 | + var buf bytes.Buffer |
760 | + writeBaseString(&buf, ot.method, ot.url, ot.appParams, oauthParams) |
761 | + base := buf.String() |
762 | + if base != ot.base { |
763 | + t.Errorf("base string for %s %s\n = %q,\n want %q", ot.method, ot.url, base, ot.base) |
764 | + } |
765 | + } |
766 | +} |
767 | + |
768 | +func TestAuthorizationHeader(t *testing.T) { |
769 | + defer func() { |
770 | + testingNonce = "" |
771 | + testingTimestamp = "" |
772 | + }() |
773 | + for _, ot := range oauthTests { |
774 | + c := Client{Credentials: ot.clientCredentials} |
775 | + testingNonce = ot.nonce |
776 | + testingTimestamp = ot.timestamp |
777 | + header := c.AuthorizationHeader(&ot.credentials, ot.method, ot.url, ot.appParams) |
778 | + if header != ot.header { |
779 | + t.Errorf("authorization header for %s %s\ngot: %s\nwant: %s", ot.method, ot.url, header, ot.header) |
780 | + } |
781 | + } |
782 | +} |
783 | |
784 | === added file 'plugins/twitter/twitter.go' |
785 | --- plugins/twitter/twitter.go 1970-01-01 00:00:00 +0000 |
786 | +++ plugins/twitter/twitter.go 2014-07-23 10:36:01 +0000 |
787 | @@ -0,0 +1,214 @@ |
788 | +/* |
789 | + Copyright 2014 Canonical Ltd. |
790 | + Authors: James Henstridge <james.henstridge@canonical.com> |
791 | + |
792 | + This program is free software: you can redistribute it and/or modify it |
793 | + under the terms of the GNU General Public License version 3, as published |
794 | + by the Free Software Foundation. |
795 | + |
796 | + This program is distributed in the hope that it will be useful, but |
797 | + WITHOUT ANY WARRANTY; without even the implied warranties of |
798 | + MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
799 | + PURPOSE. See the GNU General Public License for more details. |
800 | + |
801 | + You should have received a copy of the GNU General Public License along |
802 | + with this program. If not, see <http://www.gnu.org/licenses/>. |
803 | +*/ |
804 | + |
805 | +package twitter |
806 | + |
807 | +import ( |
808 | + "encoding/json" |
809 | + "fmt" |
810 | + "net/http" |
811 | + "net/url" |
812 | + "strings" |
813 | + |
814 | + "launchpad.net/account-polld/plugins/twitter/oauth" // "github.com/garyburd/go-oauth/oauth" |
815 | + "launchpad.net/account-polld/accounts" |
816 | + "launchpad.net/account-polld/plugins" |
817 | +) |
818 | + |
819 | +var baseUrl, _ = url.Parse("https://api.twitter.com/1.1/") |
820 | + |
821 | +const twitterIcon = "/usr/share/click/preinstalled/.click/users/@all/com.ubuntu.developer.webapps.webapp-twitter/twitter.png" |
822 | + |
823 | +type twitterPlugin struct { |
824 | + lastMentionId int64 |
825 | + lastDirectMessageId int64 |
826 | +} |
827 | + |
828 | +func New() plugins.Plugin { |
829 | + return &twitterPlugin{} |
830 | +} |
831 | + |
832 | +func (p *twitterPlugin) ApplicationId() plugins.ApplicationId { |
833 | + return "com.ubuntu.developer.webapps.webapp-twitter_webapp-twitter" |
834 | +} |
835 | + |
836 | +func (p *twitterPlugin) request(authData *accounts.AuthData, path string) (*http.Response, error) { |
837 | + // Resolve path relative to API base URL. |
838 | + u, err := baseUrl.Parse(path) |
839 | + if err != nil { |
840 | + return nil, err |
841 | + } |
842 | + query := u.Query() |
843 | + u.RawQuery = "" |
844 | + |
845 | + client := oauth.Client{ |
846 | + Credentials: oauth.Credentials{ |
847 | + Token: authData.ClientId, |
848 | + Secret: authData.ClientSecret, |
849 | + }, |
850 | + } |
851 | + token := &oauth.Credentials{ |
852 | + Token: authData.AccessToken, |
853 | + Secret: authData.TokenSecret, |
854 | + } |
855 | + return client.Get(http.DefaultClient, token, u.String(), query) |
856 | +} |
857 | + |
858 | +func (p *twitterPlugin) parseStatuses(resp *http.Response) ([]plugins.PushMessage, error) { |
859 | + defer resp.Body.Close() |
860 | + decoder := json.NewDecoder(resp.Body) |
861 | + |
862 | + if resp.StatusCode != http.StatusOK { |
863 | + var result TwitterError |
864 | + if err := decoder.Decode(&result); err != nil { |
865 | + return nil, err |
866 | + } |
867 | + return nil, &result |
868 | + } |
869 | + |
870 | + var statuses []status |
871 | + if err := decoder.Decode(&statuses); err != nil { |
872 | + return nil, err |
873 | + } |
874 | + pushMsg := []plugins.PushMessage{} |
875 | + latestStatus := p.lastMentionId |
876 | + for _, s := range statuses { |
877 | + pushMsg = append(pushMsg, plugins.PushMessage{ |
878 | + Notification: plugins.Notification{ |
879 | + Card: &plugins.Card{ |
880 | + Summary: fmt.Sprintf("Mention from @%s", s.User.ScreenName), |
881 | + Body: s.Text, |
882 | + Icon: twitterIcon, |
883 | + }, |
884 | + }, |
885 | + }) |
886 | + if s.Id > latestStatus { |
887 | + latestStatus = s.Id |
888 | + } |
889 | + } |
890 | + p.lastMentionId = latestStatus |
891 | + return pushMsg, nil |
892 | +} |
893 | + |
894 | +func (p *twitterPlugin) parseDirectMessages(resp *http.Response) ([]plugins.PushMessage, error) { |
895 | + defer resp.Body.Close() |
896 | + decoder := json.NewDecoder(resp.Body) |
897 | + |
898 | + if resp.StatusCode != http.StatusOK { |
899 | + var result TwitterError |
900 | + if err := decoder.Decode(&result); err != nil { |
901 | + return nil, err |
902 | + } |
903 | + return nil, &result |
904 | + } |
905 | + |
906 | + var dms []directMessage |
907 | + if err := decoder.Decode(&dms); err != nil { |
908 | + return nil, err |
909 | + } |
910 | + pushMsg := []plugins.PushMessage{} |
911 | + latestDM := p.lastDirectMessageId |
912 | + for _, m := range dms { |
913 | + pushMsg = append(pushMsg, plugins.PushMessage{ |
914 | + Notification: plugins.Notification{ |
915 | + Card: &plugins.Card{ |
916 | + Summary: fmt.Sprintf("Direct message from @%s", m.Sender.ScreenName), |
917 | + Body: m.Text, |
918 | + Icon: twitterIcon, |
919 | + }, |
920 | + }, |
921 | + }) |
922 | + if m.Id > latestDM { |
923 | + latestDM = m.Id |
924 | + } |
925 | + } |
926 | + p.lastDirectMessageId = latestDM |
927 | + return pushMsg, nil |
928 | +} |
929 | + |
930 | +func (p *twitterPlugin) Poll(authData *accounts.AuthData) (messages []plugins.PushMessage, err error) { |
931 | + url := "statuses/mentions_timeline.json" |
932 | + if p.lastMentionId > 0 { |
933 | + url = fmt.Sprintf("%s?since_id=%d", url, p.lastMentionId) |
934 | + } |
935 | + resp, err := p.request(authData, url) |
936 | + if err != nil { |
937 | + return |
938 | + } |
939 | + messages, err = p.parseStatuses(resp) |
940 | + if err != nil { |
941 | + return |
942 | + } |
943 | + |
944 | + url = "direct_messages.json" |
945 | + if p.lastDirectMessageId > 0 { |
946 | + url = fmt.Sprintf("%s?since_id=%d", url, p.lastDirectMessageId) |
947 | + } |
948 | + resp, err = p.request(authData, url) |
949 | + if err != nil { |
950 | + return |
951 | + } |
952 | + dms, err := p.parseDirectMessages(resp) |
953 | + if err != nil { |
954 | + return |
955 | + } |
956 | + messages = append(messages, dms...) |
957 | + return |
958 | +} |
959 | + |
960 | +// Status format is described here: |
961 | +// https://dev.twitter.com/docs/api/1.1/get/statuses/mentions_timeline |
962 | +type status struct { |
963 | + Id int64 `json:"id"` |
964 | + CreatedAt string `json:"created_at"` |
965 | + User user `json:"user"` |
966 | + Text string `json:"text"` |
967 | +} |
968 | + |
969 | +// Direct message format is described here: |
970 | +// https://dev.twitter.com/docs/api/1.1/get/direct_messages |
971 | +type directMessage struct { |
972 | + Id int64 `json:"id"` |
973 | + CreatedAt string `json:"created_at"` |
974 | + Sender user `json:"sender"` |
975 | + Recipient user `json:"recipient"` |
976 | + Text string `json:"text"` |
977 | +} |
978 | + |
979 | +type user struct { |
980 | + Id int64 `json:"id"` |
981 | + ScreenName string `json:"screen_name"` |
982 | + Name string `json:"name"` |
983 | + Image string `json:"profile_image_url"` |
984 | +} |
985 | + |
986 | +// The error response format is described here: |
987 | +// https://dev.twitter.com/docs/error-codes-responses |
988 | +type TwitterError struct { |
989 | + Errors []struct { |
990 | + Code int `json:"code"` |
991 | + Message string `json:"message"` |
992 | + } `json:"errors"` |
993 | +} |
994 | + |
995 | +func (err *TwitterError) Error() string { |
996 | + messages := make([]string, len(err.Errors)) |
997 | + for i := range err.Errors { |
998 | + messages[i] = err.Errors[i].Message |
999 | + } |
1000 | + return strings.Join(messages, "\n") |
1001 | +} |
1002 | |
1003 | === added file 'plugins/twitter/twitter_test.go' |
1004 | --- plugins/twitter/twitter_test.go 1970-01-01 00:00:00 +0000 |
1005 | +++ plugins/twitter/twitter_test.go 2014-07-23 10:36:01 +0000 |
1006 | @@ -0,0 +1,455 @@ |
1007 | +/* |
1008 | + Copyright 2014 Canonical Ltd. |
1009 | + Authors: James Henstridge <james.henstridge@canonical.com> |
1010 | + |
1011 | + This program is free software: you can redistribute it and/or modify it |
1012 | + under the terms of the GNU General Public License version 3, as published |
1013 | + by the Free Software Foundation. |
1014 | + |
1015 | + This program is distributed in the hope that it will be useful, but |
1016 | + WITHOUT ANY WARRANTY; without even the implied warranties of |
1017 | + MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
1018 | + PURPOSE. See the GNU General Public License for more details. |
1019 | + |
1020 | + You should have received a copy of the GNU General Public License along |
1021 | + with this program. If not, see <http://www.gnu.org/licenses/>. |
1022 | +*/ |
1023 | +package twitter |
1024 | + |
1025 | +import ( |
1026 | + "bytes" |
1027 | + "io" |
1028 | + "net/http" |
1029 | + "testing" |
1030 | + |
1031 | + . "launchpad.net/gocheck" |
1032 | +) |
1033 | + |
1034 | +type S struct{} |
1035 | + |
1036 | +func init() { |
1037 | + Suite(S{}) |
1038 | +} |
1039 | + |
1040 | +func TestAll(t *testing.T) { |
1041 | + TestingT(t) |
1042 | +} |
1043 | + |
1044 | +// closeWraper adds a dummy Close() method to a reader |
1045 | +type closeWrapper struct { |
1046 | + io.Reader |
1047 | +} |
1048 | + |
1049 | +func (r closeWrapper) Close() error { |
1050 | + return nil |
1051 | +} |
1052 | + |
1053 | +const ( |
1054 | + errorBody = ` |
1055 | +{ |
1056 | + "errors": [ |
1057 | + { |
1058 | + "message":"Sorry, that page does not exist", |
1059 | + "code":34 |
1060 | + } |
1061 | + ] |
1062 | +}` |
1063 | + statusesBody = ` |
1064 | +[ |
1065 | + { |
1066 | + "coordinates": null, |
1067 | + "favorited": false, |
1068 | + "truncated": false, |
1069 | + "created_at": "Mon Sep 03 13:24:14 +0000 2012", |
1070 | + "id_str": "242613977966850048", |
1071 | + "entities": { |
1072 | + "urls": [ |
1073 | + |
1074 | + ], |
1075 | + "hashtags": [ |
1076 | + |
1077 | + ], |
1078 | + "user_mentions": [ |
1079 | + { |
1080 | + "name": "Jason Costa", |
1081 | + "id_str": "14927800", |
1082 | + "id": 14927800, |
1083 | + "indices": [ |
1084 | + 0, |
1085 | + 11 |
1086 | + ], |
1087 | + "screen_name": "jasoncosta" |
1088 | + }, |
1089 | + { |
1090 | + "name": "Matt Harris", |
1091 | + "id_str": "777925", |
1092 | + "id": 777925, |
1093 | + "indices": [ |
1094 | + 12, |
1095 | + 26 |
1096 | + ], |
1097 | + "screen_name": "themattharris" |
1098 | + }, |
1099 | + { |
1100 | + "name": "ThinkWall", |
1101 | + "id_str": "117426578", |
1102 | + "id": 117426578, |
1103 | + "indices": [ |
1104 | + 109, |
1105 | + 119 |
1106 | + ], |
1107 | + "screen_name": "thinkwall" |
1108 | + } |
1109 | + ] |
1110 | + }, |
1111 | + "in_reply_to_user_id_str": "14927800", |
1112 | + "contributors": null, |
1113 | + "text": "@jasoncosta @themattharris Hey! Going to be in Frisco in October. Was hoping to have a meeting to talk about @thinkwall if you're around?", |
1114 | + "retweet_count": 0, |
1115 | + "in_reply_to_status_id_str": null, |
1116 | + "id": 242613977966850048, |
1117 | + "geo": null, |
1118 | + "retweeted": false, |
1119 | + "in_reply_to_user_id": 14927800, |
1120 | + "place": null, |
1121 | + "user": { |
1122 | + "profile_sidebar_fill_color": "EEEEEE", |
1123 | + "profile_sidebar_border_color": "000000", |
1124 | + "profile_background_tile": false, |
1125 | + "name": "Andrew Spode Miller", |
1126 | + "profile_image_url": "http://a0.twimg.com/profile_images/1227466231/spode-balloon-medium_normal.jpg", |
1127 | + "created_at": "Mon Sep 22 13:12:01 +0000 2008", |
1128 | + "location": "London via Gravesend", |
1129 | + "follow_request_sent": false, |
1130 | + "profile_link_color": "F31B52", |
1131 | + "is_translator": false, |
1132 | + "id_str": "16402947", |
1133 | + "entities": { |
1134 | + "url": { |
1135 | + "urls": [ |
1136 | + { |
1137 | + "expanded_url": null, |
1138 | + "url": "http://www.linkedin.com/in/spode", |
1139 | + "indices": [ |
1140 | + 0, |
1141 | + 32 |
1142 | + ] |
1143 | + } |
1144 | + ] |
1145 | + }, |
1146 | + "description": { |
1147 | + "urls": [ |
1148 | + |
1149 | + ] |
1150 | + } |
1151 | + }, |
1152 | + "default_profile": false, |
1153 | + "contributors_enabled": false, |
1154 | + "favourites_count": 16, |
1155 | + "url": "http://www.linkedin.com/in/spode", |
1156 | + "profile_image_url_https": "https://si0.twimg.com/profile_images/1227466231/spode-balloon-medium_normal.jpg", |
1157 | + "utc_offset": 0, |
1158 | + "id": 16402947, |
1159 | + "profile_use_background_image": false, |
1160 | + "listed_count": 129, |
1161 | + "profile_text_color": "262626", |
1162 | + "lang": "en", |
1163 | + "followers_count": 2013, |
1164 | + "protected": false, |
1165 | + "notifications": null, |
1166 | + "profile_background_image_url_https": "https://si0.twimg.com/profile_background_images/16420220/twitter-background-final.png", |
1167 | + "profile_background_color": "FFFFFF", |
1168 | + "verified": false, |
1169 | + "geo_enabled": true, |
1170 | + "time_zone": "London", |
1171 | + "description": "Co-Founder/Dev (PHP/jQuery) @justFDI. Run @thinkbikes and @thinkwall for events. Ex tech journo, helps run @uktjpr. Passion for Linux and customises everything.", |
1172 | + "default_profile_image": false, |
1173 | + "profile_background_image_url": "http://a0.twimg.com/profile_background_images/16420220/twitter-background-final.png", |
1174 | + "statuses_count": 11550, |
1175 | + "friends_count": 770, |
1176 | + "following": null, |
1177 | + "show_all_inline_media": true, |
1178 | + "screen_name": "spode" |
1179 | + }, |
1180 | + "in_reply_to_screen_name": "jasoncosta", |
1181 | + "source": "<a href=\"http://www.journotwit.com\" rel=\"nofollow\">JournoTwit</a>", |
1182 | + "in_reply_to_status_id": null |
1183 | + }, |
1184 | + { |
1185 | + "coordinates": { |
1186 | + "coordinates": [ |
1187 | + 121.0132101, |
1188 | + 14.5191613 |
1189 | + ], |
1190 | + "type": "Point" |
1191 | + }, |
1192 | + "favorited": false, |
1193 | + "truncated": false, |
1194 | + "created_at": "Mon Sep 03 08:08:02 +0000 2012", |
1195 | + "id_str": "242534402280783873", |
1196 | + "entities": { |
1197 | + "urls": [ |
1198 | + |
1199 | + ], |
1200 | + "hashtags": [ |
1201 | + { |
1202 | + "text": "twitter", |
1203 | + "indices": [ |
1204 | + 49, |
1205 | + 57 |
1206 | + ] |
1207 | + } |
1208 | + ], |
1209 | + "user_mentions": [ |
1210 | + { |
1211 | + "name": "Jason Costa", |
1212 | + "id_str": "14927800", |
1213 | + "id": 14927800, |
1214 | + "indices": [ |
1215 | + 14, |
1216 | + 25 |
1217 | + ], |
1218 | + "screen_name": "jasoncosta" |
1219 | + } |
1220 | + ] |
1221 | + }, |
1222 | + "in_reply_to_user_id_str": null, |
1223 | + "contributors": null, |
1224 | + "text": "Got the shirt @jasoncosta thanks man! Loving the #twitter bird on the shirt :-)", |
1225 | + "retweet_count": 0, |
1226 | + "in_reply_to_status_id_str": null, |
1227 | + "id": 242534402280783873, |
1228 | + "geo": { |
1229 | + "coordinates": [ |
1230 | + 14.5191613, |
1231 | + 121.0132101 |
1232 | + ], |
1233 | + "type": "Point" |
1234 | + }, |
1235 | + "retweeted": false, |
1236 | + "in_reply_to_user_id": null, |
1237 | + "place": null, |
1238 | + "user": { |
1239 | + "profile_sidebar_fill_color": "EFEFEF", |
1240 | + "profile_sidebar_border_color": "EEEEEE", |
1241 | + "profile_background_tile": true, |
1242 | + "name": "Mikey", |
1243 | + "profile_image_url": "http://a0.twimg.com/profile_images/1305509670/chatMikeTwitter_normal.png", |
1244 | + "created_at": "Fri Jun 20 15:57:08 +0000 2008", |
1245 | + "location": "Singapore", |
1246 | + "follow_request_sent": false, |
1247 | + "profile_link_color": "009999", |
1248 | + "is_translator": false, |
1249 | + "id_str": "15181205", |
1250 | + "entities": { |
1251 | + "url": { |
1252 | + "urls": [ |
1253 | + { |
1254 | + "expanded_url": null, |
1255 | + "url": "http://about.me/michaelangelo", |
1256 | + "indices": [ |
1257 | + 0, |
1258 | + 29 |
1259 | + ] |
1260 | + } |
1261 | + ] |
1262 | + }, |
1263 | + "description": { |
1264 | + "urls": [ |
1265 | + |
1266 | + ] |
1267 | + } |
1268 | + }, |
1269 | + "default_profile": false, |
1270 | + "contributors_enabled": false, |
1271 | + "favourites_count": 11, |
1272 | + "url": "http://about.me/michaelangelo", |
1273 | + "profile_image_url_https": "https://si0.twimg.com/profile_images/1305509670/chatMikeTwitter_normal.png", |
1274 | + "utc_offset": 28800, |
1275 | + "id": 15181205, |
1276 | + "profile_use_background_image": true, |
1277 | + "listed_count": 61, |
1278 | + "profile_text_color": "333333", |
1279 | + "lang": "en", |
1280 | + "followers_count": 577, |
1281 | + "protected": false, |
1282 | + "notifications": null, |
1283 | + "profile_background_image_url_https": "https://si0.twimg.com/images/themes/theme14/bg.gif", |
1284 | + "profile_background_color": "131516", |
1285 | + "verified": false, |
1286 | + "geo_enabled": true, |
1287 | + "time_zone": "Hong Kong", |
1288 | + "description": "Android Applications Developer, Studying Martial Arts, Plays MTG, Food and movie junkie", |
1289 | + "default_profile_image": false, |
1290 | + "profile_background_image_url": "http://a0.twimg.com/images/themes/theme14/bg.gif", |
1291 | + "statuses_count": 11327, |
1292 | + "friends_count": 138, |
1293 | + "following": null, |
1294 | + "show_all_inline_media": true, |
1295 | + "screen_name": "mikedroid" |
1296 | + }, |
1297 | + "in_reply_to_screen_name": null, |
1298 | + "source": "<a href=\"http://twitter.com/download/android\" rel=\"nofollow\">Twitter for Android</a>", |
1299 | + "in_reply_to_status_id": null |
1300 | + } |
1301 | +]` |
1302 | + directMessagesBody = ` |
1303 | +[ |
1304 | +{ |
1305 | + "created_at": "Mon Aug 27 17:21:03 +0000 2012", |
1306 | + "entities": { |
1307 | + "hashtags": [], |
1308 | + "urls": [], |
1309 | + "user_mentions": [] |
1310 | + }, |
1311 | + "id": 240136858829479936, |
1312 | + "id_str": "240136858829479936", |
1313 | + "recipient": { |
1314 | + "contributors_enabled": false, |
1315 | + "created_at": "Thu Aug 23 19:45:07 +0000 2012", |
1316 | + "default_profile": false, |
1317 | + "default_profile_image": false, |
1318 | + "description": "Keep calm and test", |
1319 | + "favourites_count": 0, |
1320 | + "follow_request_sent": false, |
1321 | + "followers_count": 0, |
1322 | + "following": false, |
1323 | + "friends_count": 10, |
1324 | + "geo_enabled": true, |
1325 | + "id": 776627022, |
1326 | + "id_str": "776627022", |
1327 | + "is_translator": false, |
1328 | + "lang": "en", |
1329 | + "listed_count": 0, |
1330 | + "location": "San Francisco, CA", |
1331 | + "name": "Mick Jagger", |
1332 | + "notifications": false, |
1333 | + "profile_background_color": "000000", |
1334 | + "profile_background_image_url": "http://a0.twimg.com/profile_background_images/644522235/cdjlccey99gy36j3em67.jpeg", |
1335 | + "profile_background_image_url_https": "https://si0.twimg.com/profile_background_images/644522235/cdjlccey99gy36j3em67.jpeg", |
1336 | + "profile_background_tile": true, |
1337 | + "profile_image_url": "http://a0.twimg.com/profile_images/2550226257/y0ef5abcx5yrba8du0sk_normal.jpeg", |
1338 | + "profile_image_url_https": "https://si0.twimg.com/profile_images/2550226257/y0ef5abcx5yrba8du0sk_normal.jpeg", |
1339 | + "profile_link_color": "000000", |
1340 | + "profile_sidebar_border_color": "000000", |
1341 | + "profile_sidebar_fill_color": "000000", |
1342 | + "profile_text_color": "000000", |
1343 | + "profile_use_background_image": false, |
1344 | + "protected": false, |
1345 | + "screen_name": "s0c1alm3dia", |
1346 | + "show_all_inline_media": false, |
1347 | + "statuses_count": 0, |
1348 | + "time_zone": "Pacific Time (US & Canada)", |
1349 | + "url": "http://cnn.com", |
1350 | + "utc_offset": -28800, |
1351 | + "verified": false |
1352 | + }, |
1353 | + "recipient_id": 776627022, |
1354 | + "recipient_screen_name": "s0c1alm3dia", |
1355 | + "sender": { |
1356 | + "contributors_enabled": true, |
1357 | + "created_at": "Sat May 09 17:58:22 +0000 2009", |
1358 | + "default_profile": false, |
1359 | + "default_profile_image": false, |
1360 | + "description": "I taught your phone that thing you like. The Mobile Partner Engineer @Twitter. ", |
1361 | + "favourites_count": 584, |
1362 | + "follow_request_sent": false, |
1363 | + "followers_count": 10621, |
1364 | + "following": false, |
1365 | + "friends_count": 1181, |
1366 | + "geo_enabled": true, |
1367 | + "id": 38895958, |
1368 | + "id_str": "38895958", |
1369 | + "is_translator": false, |
1370 | + "lang": "en", |
1371 | + "listed_count": 190, |
1372 | + "location": "San Francisco", |
1373 | + "name": "Sean Cook", |
1374 | + "notifications": false, |
1375 | + "profile_background_color": "1A1B1F", |
1376 | + "profile_background_image_url": "http://a0.twimg.com/profile_background_images/495742332/purty_wood.png", |
1377 | + "profile_background_image_url_https": "https://si0.twimg.com/profile_background_images/495742332/purty_wood.png", |
1378 | + "profile_background_tile": true, |
1379 | + "profile_image_url": "http://a0.twimg.com/profile_images/1751506047/dead_sexy_normal.JPG", |
1380 | + "profile_image_url_https": "https://si0.twimg.com/profile_images/1751506047/dead_sexy_normal.JPG", |
1381 | + "profile_link_color": "2FC2EF", |
1382 | + "profile_sidebar_border_color": "181A1E", |
1383 | + "profile_sidebar_fill_color": "252429", |
1384 | + "profile_text_color": "666666", |
1385 | + "profile_use_background_image": true, |
1386 | + "protected": false, |
1387 | + "screen_name": "theSeanCook", |
1388 | + "show_all_inline_media": true, |
1389 | + "statuses_count": 2608, |
1390 | + "time_zone": "Pacific Time (US & Canada)", |
1391 | + "url": null, |
1392 | + "utc_offset": -28800, |
1393 | + "verified": false |
1394 | + }, |
1395 | + "sender_id": 38895958, |
1396 | + "sender_screen_name": "theSeanCook", |
1397 | + "text": "booyakasha" |
1398 | +} |
1399 | +] |
1400 | +` |
1401 | +) |
1402 | + |
1403 | +func (s S) TestParseStatuses(c *C) { |
1404 | + resp := &http.Response{ |
1405 | + StatusCode: http.StatusOK, |
1406 | + Body: closeWrapper{bytes.NewReader([]byte(statusesBody))}, |
1407 | + } |
1408 | + p := &twitterPlugin{} |
1409 | + messages, err := p.parseStatuses(resp) |
1410 | + c.Assert(err, IsNil) |
1411 | + c.Assert(len(messages), Equals, 2) |
1412 | + c.Check(messages[0].Notification.Card.Summary, Equals, "Mention from @spode") |
1413 | + c.Check(messages[0].Notification.Card.Body, Equals, "@jasoncosta @themattharris Hey! Going to be in Frisco in October. Was hoping to have a meeting to talk about @thinkwall if you're around?") |
1414 | + c.Check(messages[1].Notification.Card.Summary, Equals, "Mention from @mikedroid") |
1415 | + c.Check(messages[1].Notification.Card.Body, Equals, "Got the shirt @jasoncosta thanks man! Loving the #twitter bird on the shirt :-)") |
1416 | + c.Check(p.lastMentionId, Equals, int64(242613977966850048)) |
1417 | +} |
1418 | + |
1419 | +func (s S) TestParseStatusesError(c *C) { |
1420 | + resp := &http.Response{ |
1421 | + StatusCode: http.StatusBadRequest, |
1422 | + Body: closeWrapper{bytes.NewReader([]byte(errorBody))}, |
1423 | + } |
1424 | + p := &twitterPlugin{} |
1425 | + messages, err := p.parseStatuses(resp) |
1426 | + c.Check(messages, IsNil) |
1427 | + c.Assert(err, Not(IsNil)) |
1428 | + twErr := err.(*TwitterError) |
1429 | + c.Assert(len(twErr.Errors), Equals, 1) |
1430 | + c.Check(twErr.Errors[0].Message, Equals, "Sorry, that page does not exist") |
1431 | + c.Check(twErr.Errors[0].Code, Equals, 34) |
1432 | +} |
1433 | + |
1434 | +func (s S) TestParseDirectMessages(c *C) { |
1435 | + resp := &http.Response{ |
1436 | + StatusCode: http.StatusOK, |
1437 | + Body: closeWrapper{bytes.NewReader([]byte(directMessagesBody))}, |
1438 | + } |
1439 | + p := &twitterPlugin{} |
1440 | + messages, err := p.parseDirectMessages(resp) |
1441 | + c.Assert(err, IsNil) |
1442 | + c.Assert(len(messages), Equals, 1) |
1443 | + c.Check(messages[0].Notification.Card.Summary, Equals, "Direct message from @theSeanCook") |
1444 | + c.Check(messages[0].Notification.Card.Body, Equals, "booyakasha") |
1445 | + c.Check(p.lastDirectMessageId, Equals, int64(240136858829479936)) |
1446 | +} |
1447 | + |
1448 | +func (s S) TestParseDirectMessagesError(c *C) { |
1449 | + resp := &http.Response{ |
1450 | + StatusCode: http.StatusBadRequest, |
1451 | + Body: closeWrapper{bytes.NewReader([]byte(errorBody))}, |
1452 | + } |
1453 | + p := &twitterPlugin{} |
1454 | + messages, err := p.parseDirectMessages(resp) |
1455 | + c.Check(messages, IsNil) |
1456 | + c.Assert(err, Not(IsNil)) |
1457 | + twErr := err.(*TwitterError) |
1458 | + c.Assert(len(twErr.Errors), Equals, 1) |
1459 | + c.Check(twErr.Errors[0].Message, Equals, "Sorry, that page does not exist") |
1460 | + c.Check(twErr.Errors[0].Code, Equals, 34) |
1461 | +} |
On viernes 18 de julio de 2014 08h'49:24 ART, James Henstridge wrote: /code.launchpad .net/~jamesh/ account- polld/twitter- plugin/ +merge/ 227313
> James Henstridge has proposed merging
> lp:~jamesh/account-polld/twitter-plugin into lp:account-polld.
>
> Commit message:
> Add the Twitter polling plugin.
>
> Requested reviews:
> Sergio Schvezov (sergiusens)
>
> For more details, see:
> https:/
110 + // Resolve path relative to Graph API base URL, and add access token
this seems to be a stray comment.
250 + type user struct {
seems to be missing a gofmt/goimports call
97 + lastMentionId int64
98 + lastDirectMessageId int64
just out of curiosity, why are these int64 instead of uint64? Is there a
chance for negative Ids?
I'll do more thorough review in the morning.