Skip to content

Commit

Permalink
internal/task: post tweets via Twitter API
Browse files Browse the repository at this point in the history
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 <[email protected]>
TryBot-Result: Go Bot <[email protected]>
Trust: Dmitri Shuralyov <[email protected]>
Trust: Alexander Rakoczy <[email protected]>
Reviewed-by: Alexander Rakoczy <[email protected]>
Reviewed-by: Carlos Amedee <[email protected]>
  • Loading branch information
dmitshur committed Nov 2, 2021
1 parent 46f6bfb commit c9fe2cc
Show file tree
Hide file tree
Showing 5 changed files with 227 additions and 15 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
14 changes: 14 additions & 0 deletions internal/secret/gcp_secret_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
125 changes: 122 additions & 3 deletions internal/task/tweet.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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
}
100 changes: 88 additions & 12 deletions internal/task/tweet_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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.",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
}

0 comments on commit c9fe2cc

Please sign in to comment.