Merge lp:~jamesh/account-polld/twitter-plugin into lp:~phablet-team/account-polld/trunk

Proposed by James Henstridge
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
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.com/garyburd/go-oauth/oauth library, since Twitter requires HMAC-SHA1 request signing, which is decidedly non-trivial (this is also the reason why there are 4 auth strings in the accounts.AuthData struct).

To post a comment you must log in.
Revision history for this message
Sergio Schvezov (sergiusens) wrote :

On viernes 18 de julio de 2014 08h'49:24 ART, James Henstridge wrote:
> 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://code.launchpad.net/~jamesh/account-polld/twitter-plugin/+merge/227313

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.

14. By James Henstridge

Fix up comment, and run through gofmt.

Revision history for this message
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://dev.twitter.com/docs/twitter-libraries seem to have chosen differently.

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.

Revision history for this message
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/click/preinstalled/.click/users/@all/com.ubuntu.developer.webapps.webapp-twitter/twitter.png

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

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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+}

Subscribers

People subscribed via source and target branches