golang.org/x/build@v0.0.0-20240506185731-218518f32b70/internal/task/tweet.go (about)

     1  // Copyright 2021 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package task
     6  
     7  import (
     8  	"bytes"
     9  	"context"
    10  	"encoding/json"
    11  	"fmt"
    12  	"image"
    13  	"image/color"
    14  	"image/draw"
    15  	"image/png"
    16  	"io"
    17  	"math"
    18  	"math/rand"
    19  	"mime/multipart"
    20  	"net/http"
    21  	"strings"
    22  	"text/template"
    23  	"time"
    24  
    25  	"github.com/McKael/madon/v3"
    26  	"github.com/dghubble/oauth1"
    27  	"github.com/esimov/stackblur-go"
    28  	"golang.org/x/build/internal/secret"
    29  	"golang.org/x/build/internal/workflow"
    30  	"golang.org/x/build/maintner/maintnerd/maintapi/version"
    31  	"golang.org/x/image/font"
    32  	"golang.org/x/image/font/gofont/gomono"
    33  	"golang.org/x/image/font/opentype"
    34  	"golang.org/x/image/math/fixed"
    35  )
    36  
    37  // releaseTweet describes a tweet that announces a Go release.
    38  type releaseTweet struct {
    39  	// Kind is the kind of release being announced.
    40  	Kind ReleaseKind
    41  
    42  	// Version is the Go version that has been released.
    43  	//
    44  	// The version string must use the same format as Go tags. For example:
    45  	//   - "go1.21rc2" for a pre-release
    46  	//   - "go1.21.0" for a major Go release
    47  	//   - "go1.21.1" for a minor Go release
    48  	Version string
    49  	// SecondaryVersion is an older Go version that was also released.
    50  	// This only applies to minor releases when two releases are made.
    51  	// For example, "go1.20.9".
    52  	SecondaryVersion string
    53  
    54  	// Security is an optional sentence describing security fixes
    55  	// included in this release.
    56  	//
    57  	// The empty string means there are no security fixes to highlight.
    58  	// Past examples:
    59  	//   - "Includes a security fix for the Wasm port (CVE-2021-38297)."
    60  	//   - "Includes a security fix for archive/zip (CVE-2021-39293)."
    61  	//   - "Includes a security fix for crypto/tls (CVE-2021-34558)."
    62  	//   - "Includes security fixes for archive/zip, net, net/http/httputil, and math/big packages."
    63  	Security string
    64  
    65  	// Announcement is the announcement URL.
    66  	//
    67  	// It's applicable to all release types other than major,
    68  	// since major releases point to release notes instead.
    69  	// For example, "https://groups.google.com/g/golang-announce/c/wB1fph5RpsE/m/ZGwOsStwAwAJ".
    70  	Announcement string
    71  }
    72  
    73  type Poster interface {
    74  	// PostTweet posts a tweet with the given text and PNG image,
    75  	// both of which must be non-empty, and returns the tweet URL.
    76  	//
    77  	// ErrTweetTooLong error is returned if posting fails
    78  	// due to the tweet text length exceeding Twitter's limit.
    79  	PostTweet(text string, imagePNG []byte, altText string) (tweetURL string, _ error)
    80  }
    81  
    82  // SocialMediaTasks contains tasks related to the release tweet.
    83  type SocialMediaTasks struct {
    84  	// TwitterClient can be used to post a tweet.
    85  	TwitterClient  Poster
    86  	MastodonClient Poster
    87  
    88  	// RandomSeed is the pseudo-random number generator seed to use for presentational
    89  	// choices, such as selecting one out of many available emoji or release archives.
    90  	// The zero value means to use time.Now().UnixNano().
    91  	RandomSeed int64
    92  }
    93  
    94  func (t SocialMediaTasks) textAndImage(ctx *workflow.TaskContext, kind ReleaseKind, published []Published, security string, announcement string) (tweetText string, imagePNG []byte, imageText string, err error) {
    95  	if len(published) < 1 || len(published) > 2 {
    96  		return "", nil, "", fmt.Errorf("got %d published Go releases, TweetRelease supports only 1 or 2 at once", len(published))
    97  	}
    98  
    99  	r := releaseTweet{
   100  		Kind:         kind,
   101  		Version:      published[0].Version,
   102  		Security:     security,
   103  		Announcement: announcement,
   104  	}
   105  	if len(published) == 2 {
   106  		r.SecondaryVersion = published[1].Version
   107  	}
   108  
   109  	seed := t.RandomSeed
   110  	if seed == 0 {
   111  		seed = time.Now().UnixNano()
   112  	}
   113  	rnd := rand.New(rand.NewSource(seed))
   114  
   115  	// Generate tweet text.
   116  	tweetText, err = r.tweetText(rnd)
   117  	if err != nil {
   118  		return "", nil, "", err
   119  	}
   120  	ctx.Printf("tweet text:\n%s\n", tweetText)
   121  
   122  	// Generate tweet image.
   123  	imagePNG, imageText, err = tweetImage(published[0], rnd)
   124  	if err != nil {
   125  		return "", nil, "", err
   126  	}
   127  	ctx.Printf("tweet image:\n%s\n", imageText)
   128  	return tweetText, imagePNG, imageText, nil
   129  }
   130  
   131  // TweetRelease posts a tweet announcing that a Go release has been published.
   132  // ErrTweetTooLong is returned if the inputs result in a tweet that's too long.
   133  func (t SocialMediaTasks) TweetRelease(ctx *workflow.TaskContext, kind ReleaseKind, published []Published, security string, announcement string) (_ string, _ error) {
   134  	tweetText, imagePNG, imageText, err := t.textAndImage(ctx, kind, published, security, announcement)
   135  	if err != nil {
   136  		return "", err
   137  	}
   138  
   139  	// Post a tweet via the Twitter API.
   140  	if t.TwitterClient == nil {
   141  		return "(dry-run)", nil
   142  	}
   143  	ctx.DisableRetries()
   144  	return t.TwitterClient.PostTweet(tweetText, imagePNG, imageText)
   145  }
   146  
   147  // TrumpetRelease posts a tweet announcing that a Go release has been published.
   148  func (t SocialMediaTasks) TrumpetRelease(ctx *workflow.TaskContext, kind ReleaseKind, published []Published, security string, announcement string) (_ string, _ error) {
   149  	tweetText, imagePNG, imageText, err := t.textAndImage(ctx, kind, published, security, announcement)
   150  	if err != nil {
   151  		return "", err
   152  	}
   153  
   154  	// Post via the Mastodon API.
   155  	if t.MastodonClient == nil {
   156  		return "(dry-run)", nil
   157  	}
   158  	ctx.DisableRetries()
   159  	return t.MastodonClient.PostTweet(tweetText, imagePNG, imageText)
   160  }
   161  
   162  // tweetText generates the text to use in the announcement
   163  // tweet for release r.
   164  func (r *releaseTweet) tweetText(rnd *rand.Rand) (string, error) {
   165  	// Parse the tweet text template
   166  	// using rnd for emoji selection.
   167  	t, err := template.New("").Funcs(template.FuncMap{
   168  		"emoji": func(category string) (string, error) {
   169  			es, ok := emoji[category]
   170  			if !ok {
   171  				return "", fmt.Errorf("unknown emoji category %q", category)
   172  			}
   173  			return es[rnd.Intn(len(es))], nil
   174  		},
   175  
   176  		// short and helpers below manipulate valid Go version strings
   177  		// for the current needs of the tweet templates.
   178  		"short": func(v string) string { return strings.TrimPrefix(v, "go") },
   179  		// major extracts the major prefix of a valid Go version.
   180  		// For example, major("go1.18.4") == "1.18".
   181  		"major": func(v string) (string, error) {
   182  			x, ok := version.Go1PointX(v)
   183  			if !ok {
   184  				return "", fmt.Errorf("internal error: version.Go1PointX(%q) is not ok", v)
   185  			}
   186  			return fmt.Sprintf("1.%d", x), nil
   187  		},
   188  		// build extracts the pre-release build number of a valid Go version.
   189  		// For example, build("go1.19beta2") == "2".
   190  		"build": func(v string) (string, error) {
   191  			if i := strings.Index(v, "beta"); i != -1 {
   192  				return v[i+len("beta"):], nil
   193  			} else if i := strings.Index(v, "rc"); i != -1 {
   194  				return v[i+len("rc"):], nil
   195  			}
   196  			return "", fmt.Errorf("internal error: unhandled pre-release Go version %q", v)
   197  		},
   198  	}).Parse(tweetTextTmpl)
   199  	if err != nil {
   200  		return "", err
   201  	}
   202  
   203  	// Select the appropriate template name.
   204  	var name string
   205  	switch r.Kind {
   206  	case KindBeta:
   207  		name = "beta"
   208  	case KindRC:
   209  		name = "rc"
   210  	case KindMajor:
   211  		name = "major"
   212  	case KindMinor:
   213  		name = "minor"
   214  	default:
   215  		return "", fmt.Errorf("unknown release kind: %v", r.Kind)
   216  	}
   217  	if r.SecondaryVersion != "" && name != "minor" {
   218  		return "", fmt.Errorf("tweet template %q doesn't support more than one release; the SecondaryVersion field can only be used in minor releases", name)
   219  	}
   220  
   221  	var buf bytes.Buffer
   222  	if err := t.ExecuteTemplate(&buf, name, r); err != nil {
   223  		return "", err
   224  	}
   225  	return buf.String(), nil
   226  }
   227  
   228  const tweetTextTmpl = `{{define "minor" -}}
   229  {{emoji "release"}} Go {{.Version|short}} {{with .SecondaryVersion}}and {{.|short}} are{{else}}is{{end}} released!
   230  
   231  {{with .Security}}{{emoji "security"}} Security: {{.}}{{"\n\n"}}{{end -}}
   232  
   233  {{emoji "announce"}} Announcement: {{.Announcement}}
   234  
   235  {{emoji "download"}} Download: https://go.dev/dl/#{{.Version}}
   236  
   237  #golang{{end}}
   238  
   239  
   240  {{define "beta" -}}
   241  {{emoji "beta-release"}} Go {{.Version|major}} Beta {{.Version|build}} is released!
   242  
   243  {{with .Security}}{{emoji "security"}} Security: {{.}}{{"\n\n"}}{{end -}}
   244  
   245  {{emoji "try"}} Try it! File bugs! https://go.dev/issue/new
   246  
   247  {{emoji "announce"}} Announcement: {{.Announcement}}
   248  
   249  {{emoji "download"}} Download: https://go.dev/dl/#{{.Version}}
   250  
   251  #golang{{end}}
   252  
   253  
   254  {{define "rc" -}}
   255  {{emoji "rc-release"}} Go {{.Version|major}} Release Candidate {{.Version|build}} is released!
   256  
   257  {{with .Security}}{{emoji "security"}} Security: {{.}}{{"\n\n"}}{{end -}}
   258  
   259  {{emoji "run"}} Run it in dev! Run it in prod! File bugs! https://go.dev/issue/new
   260  
   261  {{emoji "announce"}} Announcement: {{.Announcement}}
   262  
   263  {{emoji "download"}} Download: https://go.dev/dl/#{{.Version}}
   264  
   265  #golang{{end}}
   266  
   267  
   268  {{define "major" -}}
   269  {{emoji "release"}} Go {{.Version|short}} is released!
   270  
   271  {{with .Security}}{{emoji "security"}} Security: {{.}}{{"\n\n"}}{{end -}}
   272  
   273  {{emoji "notes"}} Release notes: https://go.dev/doc/go{{.Version|major}}
   274  
   275  {{emoji "download"}} Download: https://go.dev/dl/#{{.Version}}
   276  
   277  #golang{{end}}`
   278  
   279  // emoji is an atlas of emoji for different categories.
   280  //
   281  // The more often an emoji is included in a category,
   282  // the more likely it is to be randomly chosen.
   283  var emoji = map[string][]string{
   284  	"release": {
   285  		"๐Ÿฅณ", "๐Ÿฅณ", "๐Ÿฅณ", "๐Ÿฅณ", "๐Ÿฅณ", "๐Ÿฅณ", "๐Ÿฅณ", "๐Ÿฅณ", "๐Ÿฅณ", "๐Ÿฅณ", "๐Ÿฅณ", "๐Ÿฅณ",
   286  		"๐ŸŽ‰", "๐ŸŽ‰", "๐ŸŽ‰", "๐ŸŽ‰", "๐ŸŽ‰", "๐ŸŽ‰", "๐ŸŽ‰", "๐ŸŽ‰", "๐ŸŽ‰", "๐ŸŽ‰",
   287  		"๐ŸŽŠ", "๐ŸŽŠ", "๐ŸŽŠ", "๐ŸŽŠ", "๐ŸŽŠ", "๐ŸŽŠ", "๐ŸŽŠ", "๐ŸŽŠ", "๐ŸŽŠ", "๐ŸŽŠ",
   288  		"๐ŸŒŸ", "๐ŸŒŸ", "๐ŸŒŸ", "๐ŸŒŸ", "๐ŸŒŸ", "๐ŸŒŸ", "๐ŸŒŸ", "๐ŸŒŸ",
   289  		"๐ŸŽ†", "๐ŸŽ†", "๐ŸŽ†", "๐ŸŽ†", "๐ŸŽ†", "๐ŸŽ†",
   290  		"๐Ÿ†’",
   291  		"๐Ÿ•ถ",
   292  		"๐Ÿคฏ",
   293  		"๐Ÿงจ",
   294  		"๐Ÿ’ƒ",
   295  		"๐Ÿ•",
   296  		"๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿ”ฌ",
   297  		"๐ŸŒž",
   298  	},
   299  	"beta-release": {
   300  		"๐Ÿงช", "๐Ÿงช", "๐Ÿงช", "๐Ÿงช", "๐Ÿงช", "๐Ÿงช", "๐Ÿงช", "๐Ÿงช", "๐Ÿงช", "๐Ÿงช",
   301  		"โšก๏ธ", "โšก๏ธ", "โšก๏ธ", "โšก๏ธ", "โšก๏ธ", "โšก๏ธ", "โšก๏ธ", "โšก๏ธ", "โšก๏ธ", "โšก๏ธ",
   302  		"๐Ÿ’ฅ",
   303  	},
   304  	"rc-release": {
   305  		"๐Ÿฅณ", "๐Ÿฅณ", "๐Ÿฅณ", "๐Ÿฅณ", "๐Ÿฅณ", "๐Ÿฅณ", "๐Ÿฅณ", "๐Ÿฅณ", "๐Ÿฅณ", "๐Ÿฅณ", "๐Ÿฅณ", "๐Ÿฅณ",
   306  		"๐ŸŽ‰", "๐ŸŽ‰", "๐ŸŽ‰", "๐ŸŽ‰", "๐ŸŽ‰", "๐ŸŽ‰", "๐ŸŽ‰", "๐ŸŽ‰", "๐ŸŽ‰", "๐ŸŽ‰",
   307  		"๐ŸŽŠ", "๐ŸŽŠ", "๐ŸŽŠ", "๐ŸŽŠ", "๐ŸŽŠ", "๐ŸŽŠ", "๐ŸŽŠ", "๐ŸŽŠ", "๐ŸŽŠ", "๐ŸŽŠ",
   308  		"๐ŸŒž",
   309  	},
   310  	"security": {
   311  		"๐Ÿ”", "๐Ÿ”", "๐Ÿ”", "๐Ÿ”", "๐Ÿ”",
   312  		"๐Ÿ”", "๐Ÿ”",
   313  		"๐Ÿ”’",
   314  	},
   315  	"try": {
   316  		"โš™๏ธ",
   317  	},
   318  	"run": {
   319  		"๐Ÿƒโ€โ™€๏ธ",
   320  		"๐Ÿƒโ€โ™‚๏ธ",
   321  		"๐Ÿ–",
   322  	},
   323  	"announce": {
   324  		"๐Ÿ—ฃ", "๐Ÿ—ฃ", "๐Ÿ—ฃ", "๐Ÿ—ฃ", "๐Ÿ—ฃ", "๐Ÿ—ฃ",
   325  		"๐Ÿ“ฃ", "๐Ÿ“ฃ", "๐Ÿ“ฃ", "๐Ÿ“ฃ", "๐Ÿ“ฃ", "๐Ÿ“ฃ",
   326  		"๐Ÿ“ข", "๐Ÿ“ข", "๐Ÿ“ข", "๐Ÿ“ข", "๐Ÿ“ข", "๐Ÿ“ข",
   327  		"๐Ÿ”ˆ", "๐Ÿ”ˆ", "๐Ÿ”ˆ", "๐Ÿ”ˆ", "๐Ÿ”ˆ",
   328  		"๐Ÿ“ก", "๐Ÿ“ก", "๐Ÿ“ก", "๐Ÿ“ก",
   329  		"๐Ÿ“ฐ",
   330  	},
   331  	"notes": {
   332  		"๐Ÿ“", "๐Ÿ“", "๐Ÿ“", "๐Ÿ“", "๐Ÿ“",
   333  		"๐Ÿ—’๏ธ", "๐Ÿ—’๏ธ", "๐Ÿ—’๏ธ", "๐Ÿ—’๏ธ", "๐Ÿ—’๏ธ",
   334  		"๐Ÿ“ฐ",
   335  	},
   336  	"download": {
   337  		"โฌ‡๏ธ", "โฌ‡๏ธ", "โฌ‡๏ธ", "โฌ‡๏ธ", "โฌ‡๏ธ", "โฌ‡๏ธ", "โฌ‡๏ธ", "โฌ‡๏ธ", "โฌ‡๏ธ",
   338  		"๐Ÿ“ฆ", "๐Ÿ“ฆ", "๐Ÿ“ฆ", "๐Ÿ“ฆ", "๐Ÿ“ฆ", "๐Ÿ“ฆ", "๐Ÿ“ฆ", "๐Ÿ“ฆ", "๐Ÿ“ฆ",
   339  		"๐Ÿ—ƒ",
   340  		"๐Ÿšš",
   341  	},
   342  }
   343  
   344  // tweetImage generates an image to use in the announcement
   345  // tweet for published. It returns the image encoded as PNG,
   346  // and the text displayed in the image.
   347  //
   348  // tweetImage selects a random release archive to highlight.
   349  func tweetImage(published Published, rnd *rand.Rand) (imagePNG []byte, imageText string, _ error) {
   350  	a, err := pickRandomArchive(published, rnd)
   351  	if err != nil {
   352  		return nil, "", err
   353  	}
   354  	var buf bytes.Buffer
   355  	if err := goCmdTmpl.Execute(&buf, map[string]string{
   356  		"GoVer":    published.Version,
   357  		"GOOS":     a.OS,
   358  		"GOARCH":   a.GOARCH(),
   359  		"Filename": a.Filename,
   360  		"ZeroSize": fmt.Sprintf("%*d", digits(a.Size), 0),
   361  		"HalfSize": fmt.Sprintf("%*d", digits(a.Size), a.Size/2),
   362  		"FullSize": fmt.Sprint(a.Size),
   363  	}); err != nil {
   364  		return nil, "", err
   365  	}
   366  	imageText = buf.String()
   367  	m, err := drawTerminal(imageText)
   368  	if err != nil {
   369  		return nil, "", err
   370  	}
   371  	// Encode the image in PNG format.
   372  	buf.Reset()
   373  	err = (&png.Encoder{CompressionLevel: png.BestCompression}).Encode(&buf, m)
   374  	if err != nil {
   375  		return nil, "", err
   376  	}
   377  	return buf.Bytes(), imageText, nil
   378  }
   379  
   380  var goCmdTmpl = template.Must(template.New("").Parse(`$ go install golang.org/dl/{{.GoVer}}@latest
   381  $ {{.GoVer}} download
   382  Downloaded   0.0% ({{.ZeroSize}} / {{.FullSize}} bytes) ...
   383  Downloaded  50.0% ({{.HalfSize}} / {{.FullSize}} bytes) ...
   384  Downloaded 100.0% ({{.FullSize}} / {{.FullSize}} bytes)
   385  Unpacking {{.Filename}} ...
   386  Success. You may now run '{{.GoVer}}'
   387  $ {{.GoVer}} version
   388  go version {{.GoVer}} {{.GOOS}}/{{.GOARCH}}`))
   389  
   390  // digits reports the number of digits in the integer i. i must be non-zero.
   391  func digits(i int64) int {
   392  	var n int
   393  	for ; i != 0; i /= 10 {
   394  		n++
   395  	}
   396  	return n
   397  }
   398  
   399  // pickRandomArchive picks one random release archive
   400  // to showcase in an image showing sample output from
   401  // 'go install golang.org/dl/...@latest'.
   402  func pickRandomArchive(published Published, rnd *rand.Rand) (archive WebsiteFile, _ error) {
   403  	var archives []WebsiteFile
   404  	for _, f := range published.Files {
   405  		if f.Kind != "archive" {
   406  			// Not an archive type of file, skip. The golang.org/dl commands use archives only.
   407  			continue
   408  		}
   409  		archives = append(archives, f)
   410  	}
   411  	if len(archives) == 0 {
   412  		return WebsiteFile{}, fmt.Errorf("release version %q has 0 archive files", published.Version)
   413  	}
   414  	return archives[rnd.Intn(len(archives))], nil
   415  }
   416  
   417  // drawTerminal draws an image of a terminal window
   418  // with the given text displayed.
   419  func drawTerminal(text string) (image.Image, error) {
   420  	// Load font from TTF data.
   421  	f, err := opentype.Parse(gomono.TTF)
   422  	if err != nil {
   423  		return nil, err
   424  	}
   425  
   426  	// Keep image width within 900 px, so that Twitter doesn't convert it to a lossy JPEG.
   427  	// See https://twittercommunity.com/t/upcoming-changes-to-png-image-support/118695.
   428  	const width, height = 900, 520
   429  	m := image.NewNRGBA(image.Rect(0, 0, width, height))
   430  
   431  	// Background.
   432  	draw.Draw(m, m.Bounds(), image.NewUniform(gopherBlue), image.Point{}, draw.Src)
   433  
   434  	// Shadow.
   435  	draw.DrawMask(m, m.Bounds(), image.NewUniform(shadowColor), image.Point{},
   436  		roundedRect(image.Rect(50, 80, width-50, height-80).Add(image.Point{Y: 20}), 10), image.Point{}, draw.Over)
   437  
   438  	// Blur.
   439  	m, err = stackblur.Process(m, 80)
   440  	if err != nil {
   441  		return nil, err
   442  	}
   443  
   444  	// Terminal.
   445  	draw.DrawMask(m, m.Bounds(), image.NewUniform(terminalColor), image.Point{},
   446  		roundedRect(image.Rect(50, 80, width-50, height-80), 10), image.Point{}, draw.Over)
   447  
   448  	// Text.
   449  	face, err := opentype.NewFace(f, &opentype.FaceOptions{Size: 24, DPI: 72})
   450  	if err != nil {
   451  		return nil, err
   452  	}
   453  	d := font.Drawer{Dst: m, Src: image.White, Face: face}
   454  	const lineHeight = 32
   455  	for n, line := range strings.Split(text, "\n") {
   456  		d.Dot = fixed.P(80, 135+n*lineHeight)
   457  		d.DrawString(line)
   458  	}
   459  
   460  	return m, nil
   461  }
   462  
   463  // roundedRect returns a rounded rectangle with the specified border radius.
   464  func roundedRect(r image.Rectangle, borderRadius int) image.Image {
   465  	return roundedRectangle{
   466  		r:  r,
   467  		i:  r.Inset(borderRadius),
   468  		br: borderRadius,
   469  	}
   470  }
   471  
   472  type roundedRectangle struct {
   473  	r  image.Rectangle // Outer bounds.
   474  	i  image.Rectangle // Inner bounds, border radius away from outer.
   475  	br int             // Border radius.
   476  }
   477  
   478  func (roundedRectangle) ColorModel() color.Model   { return color.Alpha16Model }
   479  func (r roundedRectangle) Bounds() image.Rectangle { return r.r }
   480  func (r roundedRectangle) At(x, y int) color.Color {
   481  	switch {
   482  	case x < r.i.Min.X && y < r.i.Min.Y:
   483  		return circle(x-r.i.Min.X, y-r.i.Min.Y, r.br)
   484  	case x > r.i.Max.X-1 && y < r.i.Min.Y:
   485  		return circle(x-(r.i.Max.X-1), y-r.i.Min.Y, r.br)
   486  	case x < r.i.Min.X && y > r.i.Max.Y-1:
   487  		return circle(x-r.i.Min.X, y-(r.i.Max.Y-1), r.br)
   488  	case x > r.i.Max.X-1 && y > r.i.Max.Y-1:
   489  		return circle(x-(r.i.Max.X-1), y-(r.i.Max.Y-1), r.br)
   490  	default:
   491  		return color.Opaque
   492  	}
   493  }
   494  func circle(x, y, r int) color.Alpha16 {
   495  	xxyy := float64(x)*float64(x) + float64(y)*float64(y)
   496  	if xxyy > float64((r+1)*(r+1)) {
   497  		return color.Transparent
   498  	} else if xxyy > float64(r*r) {
   499  		return color.Alpha16{uint16(0xFFFF * (1 - math.Sqrt(xxyy) - float64(r)))}
   500  	}
   501  	return color.Opaque
   502  }
   503  
   504  var (
   505  	// gopherBlue is the Gopher Blue primary color from the Go color palette.
   506  	//
   507  	// Reference: https://go.dev/s/brandbook.
   508  	gopherBlue = color.NRGBA{0, 173, 216, 255} // #00add8.
   509  
   510  	// terminalColor is the color used as the terminal color.
   511  	terminalColor = color.NRGBA{52, 61, 70, 255} // #343d46.
   512  
   513  	// shadowColor is the color used as the shadow color.
   514  	shadowColor = color.NRGBA{0, 0, 0, 140} // #0000008c.
   515  )
   516  
   517  type realTwitterClient struct {
   518  	twitterAPI *http.Client
   519  }
   520  
   521  type realMastodonClient struct {
   522  	client        *madon.Client
   523  	testRecipient string
   524  }
   525  
   526  // PostTweet posts a message to a Mastodon account, with specified text, image, and image alt text.
   527  // If the "client" includes a non-empty test recipient, direct the message to that recipient in a
   528  // "direct message", also known as a "private mention".
   529  func (c realMastodonClient) PostTweet(text string, imagePNG []byte, altText string) (tweetURL string, _ error) {
   530  	client := c.client
   531  
   532  	visibility := "public"
   533  
   534  	// For end-to-end hand testing, send a message to a designated recipient
   535  	if c.testRecipient != "" {
   536  		text = c.testRecipient + "\n" + text
   537  		visibility = "direct"
   538  	}
   539  
   540  	// The documentation says that the name parameter can be empty, but at least one
   541  	// Mastodon server disagrees.  Make the name match the media format, just in case
   542  	// that matters.
   543  	att, err := client.UploadMediaReader(bytes.NewReader(imagePNG), "upload.png", altText, "")
   544  	if err != nil {
   545  		return "upload failure", err
   546  	}
   547  	postParams := madon.PostStatusParams{
   548  		Text:        text,
   549  		MediaIDs:    []string{att.ID},
   550  		Visibility:  visibility,
   551  		Sensitive:   false,
   552  		SpoilerText: "",
   553  	}
   554  
   555  	status, err := client.PostStatus(postParams)
   556  	if err != nil {
   557  		return "post failure", err
   558  	}
   559  	return status.URL, nil
   560  }
   561  
   562  // PostTweet implements the TweetTasks.TwitterClient interface.
   563  func (c realTwitterClient) PostTweet(text string, imagePNG []byte, altText string) (tweetURL string, _ error) {
   564  	// Make a Twitter API call to upload PNG to upload.twitter.com.
   565  	// See https://developer.twitter.com/en/docs/twitter-api/v1/media/upload-media/api-reference/post-media-upload.
   566  	var buf bytes.Buffer
   567  	w := multipart.NewWriter(&buf)
   568  	if f, err := w.CreateFormFile("media", "image.png"); err != nil {
   569  		return "", err
   570  	} else if _, err := f.Write(imagePNG); err != nil {
   571  		return "", err
   572  	} else if err := w.Close(); err != nil {
   573  		return "", err
   574  	}
   575  	req, err := http.NewRequest(http.MethodPost, "https://upload.twitter.com/1.1/media/upload.json?media_category=tweet_image", &buf)
   576  	if err != nil {
   577  		return "", err
   578  	}
   579  	req.Header.Set("Content-Type", w.FormDataContentType())
   580  	resp, err := c.twitterAPI.Do(req)
   581  	if err != nil {
   582  		return "", err
   583  	}
   584  	defer resp.Body.Close()
   585  	if resp.StatusCode != http.StatusOK {
   586  		body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
   587  		return "", fmt.Errorf("POST /1.1/media/upload.json: non-200 OK status code: %v body: %q", resp.Status, body)
   588  	}
   589  	var media struct {
   590  		ID string `json:"media_id_string"`
   591  	}
   592  	if err := json.NewDecoder(resp.Body).Decode(&media); err != nil {
   593  		return "", err
   594  	}
   595  
   596  	// Make a Twitter API call to post a tweet with the uploaded image.
   597  	// See https://developer.twitter.com/en/docs/twitter-api/tweets/manage-tweets/api-reference/post-tweets.
   598  	var tweetReq struct {
   599  		Text  string `json:"text"`
   600  		Media struct {
   601  			MediaIDs []string `json:"media_ids"`
   602  		} `json:"media"`
   603  	}
   604  	tweetReq.Text, tweetReq.Media.MediaIDs = text, []string{media.ID}
   605  	buf.Reset()
   606  	if err := json.NewEncoder(&buf).Encode(tweetReq); err != nil {
   607  		return "", err
   608  	}
   609  	resp, err = c.twitterAPI.Post("https://api.twitter.com/2/tweets", "application/json", &buf)
   610  	if err != nil {
   611  		return "", err
   612  	}
   613  	defer resp.Body.Close()
   614  	if resp.StatusCode != http.StatusCreated {
   615  		body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
   616  		if isTweetTooLong(resp, body) {
   617  			// A friendlier error for a common error type.
   618  			return "", ErrTweetTooLong
   619  		}
   620  		return "", fmt.Errorf("POST /2/tweets: non-201 Created status code: %v body: %q", resp.Status, body)
   621  	}
   622  	var tweetResp struct {
   623  		Data struct {
   624  			ID string
   625  		}
   626  	}
   627  	if err := json.NewDecoder(resp.Body).Decode(&tweetResp); err != nil {
   628  		return "", err
   629  	}
   630  	// Use a generic "username" in the URL since finding it needs another API call.
   631  	// As long as the URL has this format, it will redirect to the canonical username.
   632  	return "https://twitter.com/username/status/" + tweetResp.Data.ID, nil
   633  }
   634  
   635  // ErrTweetTooLong is the error when a tweet is too long.
   636  var ErrTweetTooLong = fmt.Errorf("tweet text length exceeded Twitter's limit")
   637  
   638  // isTweetTooLong reports whether the Twitter API response is
   639  // known to represent a "Tweet needs to be a bit shorter." error.
   640  // See https://developer.twitter.com/en/support/twitter-api/error-troubleshooting.
   641  func isTweetTooLong(resp *http.Response, body []byte) bool {
   642  	if resp.StatusCode != http.StatusForbidden {
   643  		return false
   644  	}
   645  	var r struct{ Errors []struct{ Code int } }
   646  	if err := json.Unmarshal(body, &r); err != nil {
   647  		return false
   648  	}
   649  	return len(r.Errors) == 1 && r.Errors[0].Code == 186
   650  }
   651  
   652  // NewTwitterClient creates a Twitter API client authenticated
   653  // to make Twitter API calls using the provided credentials.
   654  func NewTwitterClient(t secret.TwitterCredentials) realTwitterClient {
   655  	config := oauth1.NewConfig(t.ConsumerKey, t.ConsumerSecret)
   656  	token := oauth1.NewToken(t.AccessTokenKey, t.AccessTokenSecret)
   657  	return realTwitterClient{twitterAPI: config.Client(context.Background(), token)}
   658  }
   659  
   660  // NewMastodonClient creates a Mastodon API client authenticated
   661  // to make Mastodon API calls using the provided credentials.
   662  // The resulting client may have been permission-limited at its creation
   663  // (e.g., only allowed to upload media and write posts).
   664  // For tests, use NewTestMastodonClient, which creates private messages
   665  // instead.
   666  func NewMastodonClient(config secret.MastodonCredentials) (realMastodonClient, error) {
   667  	tok := madon.UserToken{AccessToken: config.AccessToken}
   668  	client, err := madon.RestoreApp(config.Application, config.Instance, config.ClientKey, config.ClientSecret, &tok)
   669  	if err != nil {
   670  		return realMastodonClient{}, err
   671  	}
   672  	return realMastodonClient{client, ""}, nil
   673  }
   674  
   675  // NewTestMastodonClient creates a client that will DM the announcement to the
   676  // designated recipient for end-to-end testing. config.TestRecipient cannot be empty;
   677  // that would result in a public message, which should not happen unintentionally.
   678  func NewTestMastodonClient(config secret.MastodonCredentials, pmTarget string) (realMastodonClient, error) {
   679  	if pmTarget == "" {
   680  		panic("private message target to NewTestMastodonClient cannot be empty")
   681  	}
   682  	mc, err := NewMastodonClient(config)
   683  	mc.testRecipient = pmTarget
   684  	return mc, err
   685  }