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 }