github.com/slspeek/camlistore_namedsearch@v0.0.0-20140519202248-ed6f70f7721a/pkg/importer/twitter/twitter.go (about) 1 /* 2 Copyright 2014 The Camlistore Authors 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 // Package twitter implements a twitter.com importer. 18 package twitter 19 20 import ( 21 "errors" 22 "fmt" 23 "log" 24 "net/http" 25 "net/url" 26 "strconv" 27 "strings" 28 "time" 29 30 "camlistore.org/pkg/context" 31 "camlistore.org/pkg/httputil" 32 "camlistore.org/pkg/importer" 33 "camlistore.org/pkg/schema" 34 "camlistore.org/third_party/github.com/garyburd/go-oauth/oauth" 35 ) 36 37 const ( 38 apiURL = "https://api.twitter.com/1.1/" 39 temporaryCredentialRequestURL = "https://api.twitter.com/oauth/request_token" 40 resourceOwnerAuthorizationURL = "https://api.twitter.com/oauth/authorize" 41 tokenRequestURL = "https://api.twitter.com/oauth/access_token" 42 userInfoAPIPath = "account/verify_credentials.json" 43 44 // Permanode attributes on account node: 45 acctAttrUserID = "twitterUserID" 46 acctAttrScreenName = "twitterScreenName" 47 acctAttrUserFirst = "twitterFirstName" 48 acctAttrUserLast = "twitterLastName" 49 // TODO(mpl): refactor these 4 below into an oauth package when doing flickr. 50 acctAttrTempToken = "oauthTempToken" 51 acctAttrTempSecret = "oauthTempSecret" 52 acctAttrAccessToken = "oauthAccessToken" 53 acctAttrAccessTokenSecret = "oauthAccessTokenSecret" 54 55 tweetRequestLimit = 200 // max number of tweets we can get in a user_timeline request 56 ) 57 58 func init() { 59 importer.Register("twitter", &imp{}) 60 } 61 62 var _ importer.ImporterSetupHTMLer = (*imp)(nil) 63 64 type imp struct { 65 importer.OAuth1 // for CallbackRequestAccount and CallbackURLParameters 66 } 67 68 func (im *imp) NeedsAPIKey() bool { return true } 69 70 func (im *imp) IsAccountReady(acctNode *importer.Object) (ok bool, err error) { 71 if acctNode.Attr(acctAttrUserID) != "" && acctNode.Attr(acctAttrAccessToken) != "" { 72 return true, nil 73 } 74 return false, nil 75 } 76 77 func (im *imp) SummarizeAccount(acct *importer.Object) string { 78 ok, err := im.IsAccountReady(acct) 79 if err != nil { 80 return "Not configured; error = " + err.Error() 81 } 82 if !ok { 83 return "Not configured" 84 } 85 if acct.Attr(acctAttrUserFirst) == "" && acct.Attr(acctAttrUserLast) == "" { 86 return fmt.Sprintf("@%s", acct.Attr(acctAttrScreenName)) 87 } 88 return fmt.Sprintf("@%s (%s %s)", acct.Attr(acctAttrScreenName), 89 acct.Attr(acctAttrUserFirst), acct.Attr(acctAttrUserLast)) 90 } 91 92 func (im *imp) AccountSetupHTML(host *importer.Host) string { 93 base := host.ImporterBaseURL() + "twitter" 94 return fmt.Sprintf(` 95 <h1>Configuring Twitter</h1> 96 <p>Visit <a href='https://apps.twitter.com/'>https://apps.twitter.com/</a> and click "Create New App".</p> 97 <p>Use the following settings:</p> 98 <ul> 99 <li>Name: Does not matter. (camlistore-importer).</li> 100 <li>Description: Does not matter. (imports twitter data into camlistore).</li> 101 <li>Website: <b>%s</b></li> 102 <li>Callback URL: <b>%s</b></li> 103 </ul> 104 <p>Click "Create your Twitter application".You should be redirected to the Application Management page of your newly created application. 105 </br>Go to the API Keys tab. Copy the "API key" and "API secret" into the "Client ID" and "Client Secret" boxes above.</p> 106 `, base, base+"/callback") 107 } 108 109 // A run is our state for a given run of the importer. 110 type run struct { 111 *importer.RunContext 112 im *imp 113 oauthClient *oauth.Client // No need to guard, used read-only. 114 accessCreds *oauth.Credentials // No need to guard, used read-only. 115 } 116 117 func (r *run) oauthContext() oauthContext { 118 return oauthContext{r.Context, r.oauthClient, r.accessCreds} 119 } 120 121 func (im *imp) Run(ctx *importer.RunContext) error { 122 clientId, secret, err := ctx.Credentials() 123 if err != nil { 124 return fmt.Errorf("no API credentials: %v", err) 125 } 126 accountNode := ctx.AccountNode() 127 accessToken := accountNode.Attr(acctAttrAccessToken) 128 accessSecret := accountNode.Attr(acctAttrAccessTokenSecret) 129 if accessToken == "" || accessSecret == "" { 130 return errors.New("access credentials not found") 131 } 132 r := &run{ 133 RunContext: ctx, 134 im: im, 135 oauthClient: &oauth.Client{ 136 TemporaryCredentialRequestURI: temporaryCredentialRequestURL, 137 ResourceOwnerAuthorizationURI: resourceOwnerAuthorizationURL, 138 TokenRequestURI: tokenRequestURL, 139 Credentials: oauth.Credentials{ 140 Token: clientId, 141 Secret: secret, 142 }, 143 }, 144 accessCreds: &oauth.Credentials{ 145 Token: accessToken, 146 Secret: accessSecret, 147 }, 148 } 149 userID := ctx.AccountNode().Attr(acctAttrUserID) 150 if userID == "" { 151 return errors.New("UserID hasn't been set by account setup.") 152 } 153 154 if err := r.importTweets(userID); err != nil { 155 return err 156 } 157 return nil 158 } 159 160 type tweetItem struct { 161 Id string `json:"id_str"` 162 Text string 163 CreatedAt string `json:"created_at"` 164 } 165 166 func (r *run) importTweets(userID string) error { 167 maxId := "" 168 continueRequests := true 169 170 for continueRequests { 171 if r.Context.IsCanceled() { 172 log.Printf("Twitter importer: interrupted") 173 return context.ErrCanceled 174 } 175 176 var resp []*tweetItem 177 if err := r.oauthContext().doAPI(&resp, "statuses/user_timeline.json", 178 "user_id", userID, 179 "count", strconv.Itoa(tweetRequestLimit), 180 "max_id", maxId); err != nil { 181 return err 182 } 183 184 tweetsNode, err := r.getTopLevelNode("tweets", "Tweets") 185 if err != nil { 186 return err 187 } 188 189 itemcount := len(resp) 190 log.Printf("Twitter importer: Importing %d tweets", itemcount) 191 if itemcount < tweetRequestLimit { 192 continueRequests = false 193 } else { 194 lastTweet := resp[len(resp)-1] 195 maxId = lastTweet.Id 196 } 197 198 for _, tweet := range resp { 199 if r.Context.IsCanceled() { 200 log.Printf("Twitter importer: interrupted") 201 return context.ErrCanceled 202 } 203 err = r.importTweet(tweetsNode, tweet) 204 if err != nil { 205 log.Printf("Twitter importer: error importing tweet %s %v", tweet.Id, err) 206 continue 207 } 208 } 209 } 210 211 return nil 212 } 213 214 func (r *run) importTweet(parent *importer.Object, tweet *tweetItem) error { 215 tweetNode, err := parent.ChildPathObject(tweet.Id) 216 if err != nil { 217 return err 218 } 219 220 title := "Tweet id " + tweet.Id 221 222 createdTime, err := time.Parse(time.RubyDate, tweet.CreatedAt) 223 if err != nil { 224 return fmt.Errorf("could not parse time %q: %v", tweet.CreatedAt, err) 225 } 226 227 // TODO: import photos referenced in tweets 228 return tweetNode.SetAttrs( 229 "twitterId", tweet.Id, 230 "camliNodeType", "twitter.com:tweet", 231 "startDate", schema.RFC3339FromTime(createdTime), 232 "content", tweet.Text, 233 "title", title) 234 } 235 236 func (r *run) getTopLevelNode(path string, title string) (*importer.Object, error) { 237 tweets, err := r.RootNode().ChildPathObject(path) 238 if err != nil { 239 return nil, err 240 } 241 if err := tweets.SetAttr("title", title); err != nil { 242 return nil, err 243 } 244 return tweets, nil 245 } 246 247 // TODO(mpl): move to an api.go when we it gets bigger. 248 249 type userInfo struct { 250 ID string `json:"id_str"` 251 ScreenName string `json:"screen_name"` 252 Name string `json:"name,omitempty"` 253 } 254 255 func getUserInfo(ctx oauthContext) (userInfo, error) { 256 var ui userInfo 257 if err := ctx.doAPI(&ui, userInfoAPIPath); err != nil { 258 return ui, err 259 } 260 if ui.ID == "" { 261 return ui, fmt.Errorf("No userid returned") 262 } 263 return ui, nil 264 } 265 266 func newOauthClient(ctx *importer.SetupContext) (*oauth.Client, error) { 267 clientId, secret, err := ctx.Credentials() 268 if err != nil { 269 return nil, err 270 } 271 return &oauth.Client{ 272 TemporaryCredentialRequestURI: temporaryCredentialRequestURL, 273 ResourceOwnerAuthorizationURI: resourceOwnerAuthorizationURL, 274 TokenRequestURI: tokenRequestURL, 275 Credentials: oauth.Credentials{ 276 Token: clientId, 277 Secret: secret, 278 }, 279 }, nil 280 } 281 282 func (im *imp) ServeSetup(w http.ResponseWriter, r *http.Request, ctx *importer.SetupContext) error { 283 oauthClient, err := newOauthClient(ctx) 284 if err != nil { 285 err = fmt.Errorf("error getting OAuth client: %v", err) 286 httputil.ServeError(w, r, err) 287 return err 288 } 289 tempCred, err := oauthClient.RequestTemporaryCredentials(ctx.HTTPClient(), ctx.CallbackURL(), nil) 290 if err != nil { 291 err = fmt.Errorf("Error getting temp cred: %v", err) 292 httputil.ServeError(w, r, err) 293 return err 294 } 295 if err := ctx.AccountNode.SetAttrs( 296 acctAttrTempToken, tempCred.Token, 297 acctAttrTempSecret, tempCred.Secret, 298 ); err != nil { 299 err = fmt.Errorf("Error saving temp creds: %v", err) 300 httputil.ServeError(w, r, err) 301 return err 302 } 303 304 authURL := oauthClient.AuthorizationURL(tempCred, nil) 305 http.Redirect(w, r, authURL, 302) 306 return nil 307 } 308 309 func (im *imp) ServeCallback(w http.ResponseWriter, r *http.Request, ctx *importer.SetupContext) { 310 tempToken := ctx.AccountNode.Attr(acctAttrTempToken) 311 tempSecret := ctx.AccountNode.Attr(acctAttrTempSecret) 312 if tempToken == "" || tempSecret == "" { 313 log.Printf("twitter: no temp creds in callback") 314 httputil.BadRequestError(w, "no temp creds in callback") 315 return 316 } 317 if tempToken != r.FormValue("oauth_token") { 318 log.Printf("unexpected oauth_token: got %v, want %v", r.FormValue("oauth_token"), tempToken) 319 httputil.BadRequestError(w, "unexpected oauth_token") 320 return 321 } 322 oauthClient, err := newOauthClient(ctx) 323 if err != nil { 324 err = fmt.Errorf("error getting OAuth client: %v", err) 325 httputil.ServeError(w, r, err) 326 return 327 } 328 tokenCred, vals, err := oauthClient.RequestToken( 329 ctx.Context.HTTPClient(), 330 &oauth.Credentials{ 331 Token: tempToken, 332 Secret: tempSecret, 333 }, 334 r.FormValue("oauth_verifier"), 335 ) 336 if err != nil { 337 httputil.ServeError(w, r, fmt.Errorf("Error getting request token: %v ", err)) 338 return 339 } 340 userid := vals.Get("user_id") 341 if userid == "" { 342 httputil.ServeError(w, r, fmt.Errorf("Couldn't get user id: %v", err)) 343 return 344 } 345 if err := ctx.AccountNode.SetAttrs( 346 acctAttrAccessToken, tokenCred.Token, 347 acctAttrAccessTokenSecret, tokenCred.Secret, 348 ); err != nil { 349 httputil.ServeError(w, r, fmt.Errorf("Error setting token attributes: %v", err)) 350 return 351 } 352 353 u, err := getUserInfo(oauthContext{ctx.Context, oauthClient, tokenCred}) 354 if err != nil { 355 httputil.ServeError(w, r, fmt.Errorf("Couldn't get user info: %v", err)) 356 return 357 } 358 firstName, lastName := "", "" 359 if u.Name != "" { 360 if pieces := strings.Fields(u.Name); len(pieces) == 2 { 361 firstName = pieces[0] 362 lastName = pieces[1] 363 } 364 } 365 if err := ctx.AccountNode.SetAttrs( 366 acctAttrUserID, u.ID, 367 acctAttrUserFirst, firstName, 368 acctAttrUserLast, lastName, 369 acctAttrScreenName, u.ScreenName, 370 ); err != nil { 371 httputil.ServeError(w, r, fmt.Errorf("Error setting attribute: %v", err)) 372 return 373 } 374 http.Redirect(w, r, ctx.AccountURL(), http.StatusFound) 375 } 376 377 // oauthContext is used as a value type, wrapping a context and oauth information. 378 // 379 // TODO: move this up to pkg/importer? 380 type oauthContext struct { 381 *context.Context 382 client *oauth.Client 383 creds *oauth.Credentials 384 } 385 386 func (ctx oauthContext) doAPI(result interface{}, apiPath string, keyval ...string) error { 387 if len(keyval)%2 == 1 { 388 panic("Incorrect number of keyval arguments. must be even.") 389 } 390 form := url.Values{} 391 for i := 0; i < len(keyval); i += 2 { 392 if keyval[i+1] != "" { 393 form.Set(keyval[i], keyval[i+1]) 394 } 395 } 396 fullURL := apiURL + apiPath 397 res, err := ctx.doGet(fullURL, form) 398 if err != nil { 399 return err 400 } 401 err = httputil.DecodeJSON(res, result) 402 if err != nil { 403 return fmt.Errorf("could not parse response for %s: %v", fullURL, err) 404 } 405 return nil 406 } 407 408 func (ctx oauthContext) doGet(url string, form url.Values) (*http.Response, error) { 409 if ctx.creds == nil { 410 return nil, errors.New("No OAuth credentials. Not logged in?") 411 } 412 if ctx.client == nil { 413 return nil, errors.New("No OAuth client.") 414 } 415 res, err := ctx.client.Get(ctx.HTTPClient(), ctx.creds, url, form) 416 if err != nil { 417 return nil, fmt.Errorf("Error fetching %s: %v", url, err) 418 } 419 if res.StatusCode != http.StatusOK { 420 return nil, fmt.Errorf("Get request on %s failed with: %s", url, res.Status) 421 } 422 return res, nil 423 }