From c9fe2ccde292fd0cf4c2699da8cb3fde1842dcd5 Mon Sep 17 00:00:00 2001 From: Dmitri Shuralyov Date: Tue, 26 Oct 2021 16:15:38 -0400 Subject: [PATCH] internal/task: post tweets via Twitter API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the Twitter API to post tweets with the generated text and image. Credentials are fetched from secret manager, and OAuth 1.0a is used for authentication during Twitter API calls¹. Tested with the real Twitter API (to be sure it would work). ¹ https://developer.twitter.com/en/docs/authentication/oauth-1-0a For golang/go#40279. Fixes golang/go#47403. Change-Id: I69d0798a69c8e5a0ae8b150dd9d7673c86bdf849 Reviewed-on: https://go-review.googlesource.com/c/build/+/358898 Run-TryBot: Dmitri Shuralyov TryBot-Result: Go Bot Trust: Dmitri Shuralyov Trust: Alexander Rakoczy Reviewed-by: Alexander Rakoczy Reviewed-by: Carlos Amedee --- go.mod | 1 + go.sum | 2 + internal/secret/gcp_secret_manager.go | 14 +++ internal/task/tweet.go | 125 +++++++++++++++++++++++++- internal/task/tweet_test.go | 100 ++++++++++++++++++--- 5 files changed, 227 insertions(+), 15 deletions(-) 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) + } +}