2014-03-15 01:14:18 +00:00
|
|
|
/*
|
|
|
|
Copyright 2014 The Camlistore Authors
|
|
|
|
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
you may not use this file except in compliance with the License.
|
|
|
|
You may obtain a copy of the License at
|
|
|
|
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
See the License for the specific language governing permissions and
|
|
|
|
limitations under the License.
|
|
|
|
*/
|
|
|
|
|
|
|
|
// Package twitter implements a twitter.com importer.
|
|
|
|
package twitter
|
|
|
|
|
|
|
|
import (
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"log"
|
|
|
|
"net/http"
|
|
|
|
"net/url"
|
|
|
|
"strconv"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
2014-03-21 19:27:06 +00:00
|
|
|
"camlistore.org/pkg/context"
|
2014-03-15 01:14:18 +00:00
|
|
|
"camlistore.org/pkg/httputil"
|
|
|
|
"camlistore.org/pkg/importer"
|
|
|
|
"camlistore.org/pkg/schema"
|
|
|
|
"camlistore.org/third_party/github.com/garyburd/go-oauth/oauth"
|
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
2014-04-18 22:21:59 +00:00
|
|
|
apiURL = "https://api.twitter.com/1.1/"
|
|
|
|
temporaryCredentialRequestURL = "https://api.twitter.com/oauth/request_token"
|
|
|
|
resourceOwnerAuthorizationURL = "https://api.twitter.com/oauth/authorize"
|
|
|
|
tokenRequestURL = "https://api.twitter.com/oauth/access_token"
|
|
|
|
userInfoAPIPath = "account/verify_credentials.json"
|
|
|
|
|
|
|
|
// Permanode attributes on account node:
|
|
|
|
acctAttrUserID = "twitterUserID"
|
|
|
|
acctAttrScreenName = "twitterScreenName"
|
|
|
|
acctAttrUserFirst = "twitterFirstName"
|
|
|
|
acctAttrUserLast = "twitterLastName"
|
|
|
|
acctAttrAccessToken = "oauthAccessToken"
|
|
|
|
|
2014-03-15 01:14:18 +00:00
|
|
|
tweetRequestLimit = 200 // max number of tweets we can get in a user_timeline request
|
|
|
|
)
|
|
|
|
|
2014-04-18 22:21:59 +00:00
|
|
|
func init() {
|
|
|
|
importer.Register("twitter", &imp{})
|
|
|
|
}
|
|
|
|
|
|
|
|
var _ importer.ImporterSetupHTMLer = (*imp)(nil)
|
|
|
|
|
2014-03-15 01:14:18 +00:00
|
|
|
var (
|
|
|
|
oauthClient = &oauth.Client{
|
2014-04-18 22:21:59 +00:00
|
|
|
TemporaryCredentialRequestURI: temporaryCredentialRequestURL,
|
|
|
|
ResourceOwnerAuthorizationURI: resourceOwnerAuthorizationURL,
|
|
|
|
TokenRequestURI: tokenRequestURL,
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
|
|
|
)
|
|
|
|
|
2014-04-18 22:21:59 +00:00
|
|
|
type imp struct {
|
|
|
|
// cred are the various credentials passed around during OAUTH. First the temporary
|
|
|
|
// ones, then the access token and secret.
|
|
|
|
cred *oauth.Credentials
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
|
|
|
|
2014-04-18 22:21:59 +00:00
|
|
|
func (im *imp) NeedsAPIKey() bool { return true }
|
|
|
|
|
|
|
|
func (im *imp) IsAccountReady(acctNode *importer.Object) (ok bool, err error) {
|
|
|
|
if acctNode.Attr(acctAttrUserID) != "" && acctNode.Attr(acctAttrAccessToken) != "" {
|
|
|
|
return true, nil
|
|
|
|
}
|
|
|
|
return false, nil
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
|
|
|
|
2014-04-18 22:21:59 +00:00
|
|
|
func (im *imp) SummarizeAccount(acct *importer.Object) string {
|
|
|
|
ok, err := im.IsAccountReady(acct)
|
|
|
|
if err != nil {
|
|
|
|
return "Not configured; error = " + err.Error()
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
2014-04-18 22:21:59 +00:00
|
|
|
if !ok {
|
|
|
|
return "Not configured"
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
2014-04-18 22:21:59 +00:00
|
|
|
if acct.Attr(acctAttrUserFirst) == "" && acct.Attr(acctAttrUserLast) == "" {
|
|
|
|
return fmt.Sprintf("@%s", acct.Attr(acctAttrScreenName))
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
2014-04-18 22:21:59 +00:00
|
|
|
return fmt.Sprintf("@%s (%s %s)", acct.Attr(acctAttrScreenName),
|
|
|
|
acct.Attr(acctAttrUserFirst), acct.Attr(acctAttrUserLast))
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
|
|
|
|
2014-04-18 22:21:59 +00:00
|
|
|
func (im *imp) AccountSetupHTML(host *importer.Host) string {
|
|
|
|
base := host.ImporterBaseURL() + "twitter"
|
|
|
|
return fmt.Sprintf(`
|
|
|
|
<h1>Configuring Twitter</h1>
|
|
|
|
<p>Visit <a href='https://apps.twitter.com/'>https://apps.twitter.com/</a> and click "Create New App".</p>
|
|
|
|
<p>Use the following settings:</p>
|
|
|
|
<ul>
|
|
|
|
<li>Name: Does not matter. (camlistore-importer).</li>
|
|
|
|
<li>Description: Does not matter. (imports twitter data into camlistore).</li>
|
|
|
|
<li>Website: <b>%s</b></li>
|
|
|
|
<li>Callback URL: <b>%s</b></li>
|
|
|
|
</ul>
|
|
|
|
<p>Click "Create your Twitter application".You should be redirected to the Application Management page of your newly created application.
|
|
|
|
</br>Go to the API Keys tab. Copy the "API key" and "API secret" into the "Client ID" and "Client Secret" boxes above.</p>
|
|
|
|
`, base, base+"/callback")
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
|
|
|
|
2014-04-18 22:21:59 +00:00
|
|
|
// A run is our state for a given run of the importer.
|
|
|
|
type run struct {
|
|
|
|
*importer.RunContext
|
|
|
|
im *imp
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
|
|
|
|
2014-04-18 22:21:59 +00:00
|
|
|
func (im *imp) Run(ctx *importer.RunContext) error {
|
|
|
|
r := &run{
|
|
|
|
RunContext: ctx,
|
|
|
|
im: im,
|
|
|
|
}
|
|
|
|
userID := ctx.AccountNode().Attr(acctAttrUserID)
|
|
|
|
if userID == "" {
|
|
|
|
return errors.New("UserID hasn't been set by account setup.")
|
|
|
|
}
|
2014-03-15 01:14:18 +00:00
|
|
|
|
2014-04-18 22:21:59 +00:00
|
|
|
if err := r.importTweets(userID); err != nil {
|
2014-03-15 01:14:18 +00:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
type tweetItem struct {
|
|
|
|
Id string `json:"id_str"`
|
|
|
|
Text string
|
|
|
|
CreatedAt string `json:"created_at"`
|
|
|
|
}
|
|
|
|
|
2014-04-18 22:21:59 +00:00
|
|
|
func (r *run) importTweets(userID string) error {
|
2014-03-15 01:14:18 +00:00
|
|
|
maxId := ""
|
|
|
|
continueRequests := true
|
|
|
|
|
|
|
|
for continueRequests {
|
2014-04-18 22:21:59 +00:00
|
|
|
if r.Context.IsCanceled() {
|
2014-03-15 01:14:18 +00:00
|
|
|
log.Printf("Twitter importer: interrupted")
|
2014-03-21 19:27:06 +00:00
|
|
|
return context.ErrCanceled
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
var resp []*tweetItem
|
2014-04-18 22:21:59 +00:00
|
|
|
if err := r.im.doAPI(r.Context, &resp, "statuses/user_timeline.json",
|
|
|
|
"user_id", userID,
|
|
|
|
"count", strconv.Itoa(tweetRequestLimit),
|
|
|
|
"max_id", maxId); err != nil {
|
2014-03-15 01:14:18 +00:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2014-04-18 22:21:59 +00:00
|
|
|
tweetsNode, err := r.getTopLevelNode("tweets", "Tweets")
|
2014-03-15 01:14:18 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
itemcount := len(resp)
|
|
|
|
log.Printf("Twitter importer: Importing %d tweets", itemcount)
|
|
|
|
if itemcount < tweetRequestLimit {
|
|
|
|
continueRequests = false
|
|
|
|
} else {
|
|
|
|
lastTweet := resp[len(resp)-1]
|
|
|
|
maxId = lastTweet.Id
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, tweet := range resp {
|
2014-04-18 22:21:59 +00:00
|
|
|
if r.Context.IsCanceled() {
|
2014-03-15 01:14:18 +00:00
|
|
|
log.Printf("Twitter importer: interrupted")
|
2014-03-21 19:27:06 +00:00
|
|
|
return context.ErrCanceled
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
2014-04-18 22:21:59 +00:00
|
|
|
err = r.importTweet(tweetsNode, tweet)
|
2014-03-15 01:14:18 +00:00
|
|
|
if err != nil {
|
|
|
|
log.Printf("Twitter importer: error importing tweet %s %v", tweet.Id, err)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2014-04-18 22:21:59 +00:00
|
|
|
func (r *run) importTweet(parent *importer.Object, tweet *tweetItem) error {
|
2014-03-15 01:14:18 +00:00
|
|
|
tweetNode, err := parent.ChildPathObject(tweet.Id)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
title := "Tweet id " + tweet.Id
|
|
|
|
|
|
|
|
createdTime, err := time.Parse(time.RubyDate, tweet.CreatedAt)
|
|
|
|
if err != nil {
|
2014-04-18 22:21:59 +00:00
|
|
|
return fmt.Errorf("could not parse time %q: %v", tweet.CreatedAt, err)
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// TODO: import photos referenced in tweets
|
|
|
|
return tweetNode.SetAttrs(
|
|
|
|
"twitterId", tweet.Id,
|
|
|
|
"camliNodeType", "twitter.com:tweet",
|
|
|
|
"startDate", schema.RFC3339FromTime(createdTime),
|
|
|
|
"content", tweet.Text,
|
|
|
|
"title", title)
|
|
|
|
}
|
|
|
|
|
2014-04-18 22:21:59 +00:00
|
|
|
func (r *run) getTopLevelNode(path string, title string) (*importer.Object, error) {
|
|
|
|
tweets, err := r.RootNode().ChildPathObject(path)
|
2014-03-15 01:14:18 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2014-04-18 22:21:59 +00:00
|
|
|
if err := tweets.SetAttr("title", title); err != nil {
|
2014-03-15 01:14:18 +00:00
|
|
|
return nil, err
|
|
|
|
}
|
2014-04-18 22:21:59 +00:00
|
|
|
return tweets, nil
|
|
|
|
}
|
2014-03-15 01:14:18 +00:00
|
|
|
|
2014-04-18 22:21:59 +00:00
|
|
|
// TODO(mpl): move to an api.go when we it gets bigger.
|
|
|
|
|
|
|
|
type userInfo struct {
|
|
|
|
ID string `json:"id_str"`
|
|
|
|
ScreenName string `json:"screen_name"`
|
|
|
|
Name string `json:"name,omitempty"`
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
|
|
|
|
2014-04-18 22:21:59 +00:00
|
|
|
func (im *imp) getUserInfo(ctx *context.Context) (userInfo, error) {
|
|
|
|
var ui userInfo
|
|
|
|
if err := im.doAPI(ctx, &ui, userInfoAPIPath); err != nil {
|
|
|
|
return ui, err
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
2014-04-18 22:21:59 +00:00
|
|
|
if ui.ID == "" {
|
|
|
|
return ui, fmt.Errorf("No userid returned")
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
2014-04-18 22:21:59 +00:00
|
|
|
return ui, nil
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
|
|
|
|
2014-04-18 22:21:59 +00:00
|
|
|
func (im *imp) doAPI(ctx *context.Context, result interface{}, apiPath string, keyval ...string) error {
|
2014-03-15 01:14:18 +00:00
|
|
|
if len(keyval)%2 == 1 {
|
2014-04-18 22:21:59 +00:00
|
|
|
panic("Incorrect number of keyval arguments. must be even.")
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if im.cred == nil {
|
|
|
|
return fmt.Errorf("No authentication creds")
|
|
|
|
}
|
|
|
|
|
|
|
|
form := url.Values{}
|
|
|
|
for i := 0; i < len(keyval); i += 2 {
|
|
|
|
if keyval[i+1] != "" {
|
|
|
|
form.Set(keyval[i], keyval[i+1])
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2014-04-18 22:21:59 +00:00
|
|
|
fullURL := apiURL + apiPath
|
|
|
|
res, err := im.doGet(ctx, fullURL, form)
|
2014-03-15 01:14:18 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
err = httputil.DecodeJSON(res, result)
|
|
|
|
if err != nil {
|
2014-04-18 22:21:59 +00:00
|
|
|
return fmt.Errorf("could not parse response for %s: %v", fullURL, err)
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
2014-04-18 22:21:59 +00:00
|
|
|
return nil
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
|
|
|
|
2014-04-18 22:21:59 +00:00
|
|
|
func (im *imp) doGet(ctx *context.Context, url string, form url.Values) (*http.Response, error) {
|
2014-03-15 01:14:18 +00:00
|
|
|
if im.cred == nil {
|
2014-04-18 22:21:59 +00:00
|
|
|
return nil, errors.New("No OAUTH credentials. Not logged in?")
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
2014-04-18 22:21:59 +00:00
|
|
|
res, err := oauthClient.Get(ctx.HTTPClient(), im.cred, url, form)
|
2014-03-15 01:14:18 +00:00
|
|
|
if err != nil {
|
2014-04-18 22:21:59 +00:00
|
|
|
return nil, fmt.Errorf("Error fetching %s: %v", url, err)
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
|
|
|
if res.StatusCode != http.StatusOK {
|
2014-04-18 22:21:59 +00:00
|
|
|
return nil, fmt.Errorf("Get request on %s failed with: %s", url, res.Status)
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
|
|
|
return res, nil
|
|
|
|
}
|
|
|
|
|
2014-04-18 22:21:59 +00:00
|
|
|
func auth(ctx *importer.SetupContext) (*oauth.Credentials, error) {
|
|
|
|
clientId, secret, err := ctx.Credentials()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
2014-04-18 22:21:59 +00:00
|
|
|
return &oauth.Credentials{
|
|
|
|
Token: clientId,
|
|
|
|
Secret: secret,
|
|
|
|
}, nil
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
|
|
|
|
2014-04-18 22:21:59 +00:00
|
|
|
func (im *imp) ServeSetup(w http.ResponseWriter, r *http.Request, ctx *importer.SetupContext) error {
|
|
|
|
cred, err := auth(ctx)
|
2014-03-15 01:14:18 +00:00
|
|
|
if err != nil {
|
2014-04-18 22:21:59 +00:00
|
|
|
err = fmt.Errorf("Error getting API credentials: %v", err)
|
|
|
|
httputil.ServeError(w, r, err)
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
oauthClient.Credentials = *cred
|
|
|
|
tempCred, err := oauthClient.RequestTemporaryCredentials(ctx.HTTPClient(), ctx.CallbackURL(), nil)
|
|
|
|
if err != nil {
|
|
|
|
err = fmt.Errorf("Error getting temp cred: %v", err)
|
|
|
|
httputil.ServeError(w, r, err)
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
|
|
|
im.cred = tempCred
|
|
|
|
|
|
|
|
authURL := oauthClient.AuthorizationURL(tempCred, nil)
|
|
|
|
http.Redirect(w, r, authURL, 302)
|
2014-04-18 22:21:59 +00:00
|
|
|
return nil
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|
|
|
|
|
2014-04-18 22:21:59 +00:00
|
|
|
func (im *imp) ServeCallback(w http.ResponseWriter, r *http.Request, ctx *importer.SetupContext) {
|
2014-03-15 01:14:18 +00:00
|
|
|
if im.cred.Token != r.FormValue("oauth_token") {
|
2014-04-18 22:21:59 +00:00
|
|
|
log.Printf("unexpected oauth_token: got %v, want %v", r.FormValue("oauth_token"), im.cred.Token)
|
|
|
|
httputil.BadRequestError(w, "unexpected oauth_token")
|
2014-03-15 01:14:18 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2014-04-18 22:21:59 +00:00
|
|
|
tokenCred, vals, err := oauthClient.RequestToken(ctx.Context.HTTPClient(), im.cred, r.FormValue("oauth_verifier"))
|
2014-03-15 01:14:18 +00:00
|
|
|
if err != nil {
|
2014-04-18 22:21:59 +00:00
|
|
|
httputil.ServeError(w, r, fmt.Errorf("Error getting request token: %v ", err))
|
2014-03-15 01:14:18 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
userid := vals.Get("user_id")
|
|
|
|
if userid == "" {
|
2014-04-18 22:21:59 +00:00
|
|
|
httputil.ServeError(w, r, fmt.Errorf("Couldn't get user id: %v", err))
|
2014-03-15 01:14:18 +00:00
|
|
|
return
|
|
|
|
}
|
2014-04-18 22:21:59 +00:00
|
|
|
im.cred = tokenCred
|
2014-03-15 01:14:18 +00:00
|
|
|
|
2014-04-18 22:21:59 +00:00
|
|
|
u, err := im.getUserInfo(ctx.Context)
|
|
|
|
if err != nil {
|
|
|
|
httputil.ServeError(w, r, fmt.Errorf("Couldn't get user info: %v", err))
|
|
|
|
return
|
|
|
|
}
|
|
|
|
firstName, lastName := "", ""
|
|
|
|
if u.Name != "" {
|
|
|
|
if pieces := strings.Fields(u.Name); len(pieces) == 2 {
|
|
|
|
firstName = pieces[0]
|
|
|
|
lastName = pieces[1]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if err := ctx.AccountNode.SetAttrs(
|
|
|
|
acctAttrUserID, u.ID,
|
|
|
|
acctAttrUserFirst, firstName,
|
|
|
|
acctAttrUserLast, lastName,
|
|
|
|
acctAttrScreenName, u.ScreenName,
|
|
|
|
acctAttrAccessToken, tokenCred.Token,
|
|
|
|
); err != nil {
|
|
|
|
httputil.ServeError(w, r, fmt.Errorf("Error setting attribute: %v", err))
|
|
|
|
return
|
|
|
|
}
|
|
|
|
http.Redirect(w, r, ctx.AccountURL(), http.StatusFound)
|
2014-03-15 01:14:18 +00:00
|
|
|
}
|