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  }