diff --git a/go.mod b/go.mod index af5ce11fa1..409189dc0b 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f github.com/creack/pty v1.1.15 github.com/davecgh/go-spew v1.1.1 + github.com/dghubble/oauth1 v0.7.0 github.com/esimov/stackblur-go v1.0.1 github.com/gliderlabs/ssh v0.3.3 github.com/golang-migrate/migrate/v4 v4.15.0-beta.3 diff --git a/go.sum b/go.sum index 041878844a..3ceb94095d 100644 --- a/go.sum +++ b/go.sum @@ -179,6 +179,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= +github.com/dghubble/oauth1 v0.7.0 h1:AlpZdbRiJM4XGHIlQ8BuJ/wlpGwFEJNnB4Mc+78tA/w= +github.com/dghubble/oauth1 v0.7.0/go.mod h1:8pFdfPkv/jr8mkChVbNVuJ0suiHe278BtWI4Tk1ujxk= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dhui/dktest v0.3.4 h1:VbUEcaSP+U2/yUr9d2JhSThXYEnDlGabRSHe2rIE46E= github.com/dhui/dktest v0.3.4/go.mod h1:4m4n6lmXlmVfESth7mzdcv8nBI5mOb5UROPqjM02csU= diff --git a/internal/secret/gcp_secret_manager.go b/internal/secret/gcp_secret_manager.go index e691516a26..8cf43b4514 100644 --- a/internal/secret/gcp_secret_manager.go +++ b/internal/secret/gcp_secret_manager.go @@ -61,6 +61,20 @@ const ( // NameSendGridAPIKey is the secret name for a Go project SendGrid API key. // This API key only allows sending email. NameSendGridAPIKey = "sendgrid-sendonly-api-key" + + // NameTwitterAPISecret is the secret name for Twitter API credentials for + // posting tweets from the Go project's Twitter account (twitter.com/golang). + // + // The secret value encodes relevant keys and their secrets as + // a JSON object: + // + // { + // "ConsumerKey": "...", + // "ConsumerSecret": "...", + // "AccessTokenKey": "...", + // "AccessTokenSecret": "..." + // } + NameTwitterAPISecret = "twitter-api-secret" ) type secretClient interface { diff --git a/internal/task/tweet.go b/internal/task/tweet.go index 4724e4ec22..ea728260af 100644 --- a/internal/task/tweet.go +++ b/internal/task/tweet.go @@ -6,21 +6,28 @@ package task import ( "bytes" + "context" "encoding/json" "fmt" "image" "image/color" "image/draw" "image/png" + "io" "math" "math/rand" + "mime/multipart" "net/http" + "net/url" "strings" "text/template" "time" + "github.com/dghubble/oauth1" "github.com/esimov/stackblur-go" "github.com/golang/freetype/truetype" + "golang.org/x/build/buildenv" + "golang.org/x/build/internal/secret" "golang.org/x/build/internal/workflow" "golang.org/x/image/font" "golang.org/x/image/font/gofont/gomono" @@ -148,7 +155,7 @@ func tweetRelease(ctx workflow.TaskContext, r ReleaseTweet, dryRun bool) (tweetU } // Generate tweet image. - _, imageText, err := tweetImage(r.Version, rnd) + imagePNG, imageText, err := tweetImage(r.Version, rnd) if err != nil { return "", err } @@ -160,8 +167,12 @@ func tweetRelease(ctx workflow.TaskContext, r ReleaseTweet, dryRun bool) (tweetU if dryRun { return "(dry-run)", nil } - // TODO(golang.org/issue/47403): Use Twitter API. - return "", fmt.Errorf("use of twitter API not implemented yet") + cl, err := twitterClient() + if err != nil { + return "", err + } + tweetURL, err = postTweet(cl, tweetText, imagePNG) + return tweetURL, err } // tweetText generates the text to use in the announcement @@ -568,3 +579,111 @@ var ( // shadowColor is the color used as the shadow color. shadowColor = color.NRGBA{0, 0, 0, 140} // #0000008c. ) + +// postTweet posts a tweet with the provided text and image. +// twitterAPI is used to make Twitter API calls. +func postTweet(twitterAPI *http.Client, text string, imagePNG []byte) (tweetURL string, _ error) { + // Make a Twitter API call to upload PNG to upload.twitter.com. + // See https://developer.twitter.com/en/docs/twitter-api/v1/media/upload-media/api-reference/post-media-upload. + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + if f, err := w.CreateFormFile("media", "image.png"); err != nil { + return "", err + } else if _, err := f.Write(imagePNG); err != nil { + return "", err + } else if err := w.Close(); err != nil { + return "", err + } + req, err := http.NewRequest(http.MethodPost, "https://upload.twitter.com/1.1/media/upload.json?media_category=tweet_image", &buf) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", w.FormDataContentType()) + resp, err := twitterAPI.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) + return "", fmt.Errorf("POST media/upload: non-200 OK status code: %v body: %q", resp.Status, body) + } + var media struct { + ID string `json:"media_id_string"` + } + if err := json.NewDecoder(resp.Body).Decode(&media); err != nil { + return "", err + } + + // Make a Twitter API call to update status with uploaded image. + // See https://developer.twitter.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/post-statuses-update. + resp, err = twitterAPI.PostForm("https://api.twitter.com/1.1/statuses/update.json", url.Values{ + "status": []string{text}, + "media_ids": []string{media.ID}, + }) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) + if isTweetTooLong(resp, body) { + // A friendlier error for a common error type. + return "", ErrTweetTooLong + } + return "", fmt.Errorf("POST statuses/update: non-200 OK status code: %v body: %q", resp.Status, body) + } + var tweet struct { + ID string `json:"id_str"` + User struct { + ScreenName string `json:"screen_name"` + } + } + if err := json.NewDecoder(resp.Body).Decode(&tweet); err != nil { + return "", err + } + return "https://twitter.com/" + tweet.User.ScreenName + "/status/" + tweet.ID, nil +} + +// ErrTweetTooLong is the error when a tweet is too long. +var ErrTweetTooLong = fmt.Errorf("tweet text length exceeded Twitter's limit") + +// isTweetTooLong reports whether the Twitter API response is +// known to represent a "Tweet needs to be a bit shorter." error. +// See https://developer.twitter.com/en/support/twitter-api/error-troubleshooting. +func isTweetTooLong(resp *http.Response, body []byte) bool { + if resp.StatusCode != http.StatusForbidden { + return false + } + var r struct{ Errors []struct{ Code int } } + if err := json.Unmarshal(body, &r); err != nil { + return false + } + return len(r.Errors) == 1 && r.Errors[0].Code == 186 +} + +// twitterClient creates an HTTP client authenticated to make +// Twitter API calls on behalf of the twitter.com/golang account. +func twitterClient() (*http.Client, error) { + sc, err := secret.NewClientInProject(buildenv.Production.ProjectName) + if err != nil { + return nil, err + } + defer sc.Close() + secretJSON, err := sc.Retrieve(context.Background(), secret.NameTwitterAPISecret) + if err != nil { + return nil, err + } + var s struct { + ConsumerKey string + ConsumerSecret string + AccessTokenKey string + AccessTokenSecret string + } + if err := json.Unmarshal([]byte(secretJSON), &s); err != nil { + return nil, err + } + config := oauth1.NewConfig(s.ConsumerKey, s.ConsumerSecret) + token := oauth1.NewToken(s.AccessTokenKey, s.AccessTokenSecret) + return config.Client(context.Background(), token), nil +} diff --git a/internal/task/tweet_test.go b/internal/task/tweet_test.go index 91d21fb987..590b2e024f 100644 --- a/internal/task/tweet_test.go +++ b/internal/task/tweet_test.go @@ -2,16 +2,17 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package task_test +package task import ( "bytes" "context" "fmt" "io" + "net/http" + "net/http/httptest" "testing" - "golang.org/x/build/internal/task" "golang.org/x/build/internal/workflow" ) @@ -25,14 +26,14 @@ func TestTweetRelease(t *testing.T) { tests := [...]struct { name string - taskFn func(workflow.TaskContext, task.ReleaseTweet, bool) (string, error) - in task.ReleaseTweet + taskFn func(workflow.TaskContext, ReleaseTweet, bool) (string, error) + in ReleaseTweet wantLog string }{ { name: "minor", - taskFn: task.TweetMinorRelease, - in: task.ReleaseTweet{ + taskFn: TweetMinorRelease, + in: ReleaseTweet{ Version: "go1.17.1", SecondaryVersion: "go1.16.8", Security: "Includes security fixes for A and B.", @@ -62,8 +63,8 @@ go version go1.17.1 linux/arm64` + "\n", }, { name: "beta", - taskFn: task.TweetBetaRelease, - in: task.ReleaseTweet{ + taskFn: TweetBetaRelease, + in: ReleaseTweet{ Version: "go1.17beta1", Announcement: "https://groups.google.com/g/golang-announce/c/i4EliPDV9Ok/m/MxA-nj53AAAJ", RandomSeed: 678, @@ -91,8 +92,8 @@ go version go1.17beta1 darwin/amd64` + "\n", }, { name: "rc", - taskFn: task.TweetRCRelease, - in: task.ReleaseTweet{ + taskFn: TweetRCRelease, + in: ReleaseTweet{ Version: "go1.17rc2", Announcement: "https://groups.google.com/g/golang-announce/c/yk30ovJGXWY/m/p9uUnKbbBQAJ", RandomSeed: 456, @@ -120,8 +121,8 @@ go version go1.17rc2 windows/arm64` + "\n", }, { name: "major", - taskFn: task.TweetMajorRelease, - in: task.ReleaseTweet{ + taskFn: TweetMajorRelease, + in: ReleaseTweet{ Version: "go1.17", Security: "Includes a super duper security fix (CVE-123).", RandomSeed: 123, @@ -173,3 +174,78 @@ type fmtWriter struct{ w io.Writer } func (f fmtWriter) Printf(format string, v ...interface{}) { fmt.Fprintf(f.w, format, v...) } + +func TestPostTweet(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("upload.twitter.com/1.1/media/upload.json", func(w http.ResponseWriter, req *http.Request) { + if got, want := req.Method, http.MethodPost; got != want { + t.Errorf("media/upload: got method %s, want %s", got, want) + return + } + if got, want := req.FormValue("media_category"), "tweet_image"; got != want { + t.Errorf("media/upload: got media_category=%q, want %q", got, want) + } + f, hdr, err := req.FormFile("media") + if err != nil { + t.Errorf("media/upload: error getting image file: %v", err) + return + } + if got, want := hdr.Filename, "image.png"; got != want { + t.Errorf("media/upload: got file name=%q, want %q", got, want) + } + if got, want := mustRead(f), "image-png-bytes"; got != want { + t.Errorf("media/upload: got file content=%q, want %q", got, want) + return + } + mustWrite(w, `{"media_id_string": "media-123"}`) + }) + mux.HandleFunc("api.twitter.com/1.1/statuses/update.json", func(w http.ResponseWriter, req *http.Request) { + if got, want := req.Method, http.MethodPost; got != want { + t.Errorf("statuses/update: got method %s, want %s", got, want) + return + } + if got, want := req.FormValue("status"), "tweet-text"; got != want { + t.Errorf("statuses/update: got status=%q, want %q", got, want) + } + if got, want := req.FormValue("media_ids"), "media-123"; got != want { + t.Errorf("statuses/update: got media_ids=%q, want %q", got, want) + } + mustWrite(w, `{"id_str": "tweet-123", "user": {"screen_name": "golang"}}`) + }) + httpClient := &http.Client{Transport: localRoundTripper{mux}} + + tweetURL, err := postTweet(httpClient, "tweet-text", []byte("image-png-bytes")) + if err != nil { + t.Fatal("postTweet:", err) + } + if got, want := tweetURL, "https://twitter.com/golang/status/tweet-123"; got != want { + t.Errorf("got tweetURL=%q, want %q", got, want) + } +} + +// localRoundTripper is an http.RoundTripper that executes HTTP transactions +// by using handler directly, instead of going over an HTTP connection. +type localRoundTripper struct { + handler http.Handler +} + +func (l localRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + w := httptest.NewRecorder() + l.handler.ServeHTTP(w, req) + return w.Result(), nil +} + +func mustRead(r io.Reader) string { + b, err := io.ReadAll(r) + if err != nil { + panic(err) + } + return string(b) +} + +func mustWrite(w io.Writer, s string) { + _, err := io.WriteString(w, s) + if err != nil { + panic(err) + } +}