package main import ( "bytes" "fmt" "image" _ "image/gif" "image/jpeg" _ "image/png" "io" "net/http" "os" "path" "strings" "tweetdistributor/discord" "tweetdistributor/output" "unicode/utf8" "github.com/bwmarrin/discordgo" "golang.org/x/image/draw" _ "golang.org/x/image/webp" ) const maxTweetLength = 140 const maxImagesPerPost = 4 // Bluesky rejects blobs over 2,000,000 bytes; Twitter allows up to 5MB. const maxImageBytes = 2_000_000 // shrinkImage re-encodes (and if necessary downscales) an image until it // fits within maxImageBytes. Images already small enough pass through // untouched. func shrinkImage(img output.Image) (output.Image, error) { if len(img.Data) <= maxImageBytes { return img, nil } src, _, err := image.Decode(bytes.NewReader(img.Data)) if err != nil { return output.Image{}, fmt.Errorf("decoding %s: %w", img.Filename, err) } for scale := 1.0; scale > 0.05; scale *= 0.7 { width := int(float64(src.Bounds().Dx()) * scale) height := int(float64(src.Bounds().Dy()) * scale) if width < 1 || height < 1 { break } scaled := image.NewRGBA(image.Rect(0, 0, width, height)) draw.CatmullRom.Scale(scaled, scaled.Bounds(), src, src.Bounds(), draw.Src, nil) var buf bytes.Buffer if err := jpeg.Encode(&buf, scaled, &jpeg.Options{Quality: 85}); err != nil { return output.Image{}, fmt.Errorf("encoding %s: %w", img.Filename, err) } if buf.Len() <= maxImageBytes { filename := strings.TrimSuffix(img.Filename, path.Ext(img.Filename)) + ".jpg" return output.Image{ Data: buf.Bytes(), ContentType: "image/jpeg", Filename: filename, }, nil } } return output.Image{}, fmt.Errorf("%s could not be shrunk below %d bytes", img.Filename, maxImageBytes) } func downloadImages(attachments []*discordgo.MessageAttachment) ([]output.Image, error) { var images []output.Image for _, attachment := range attachments { if !strings.HasPrefix(attachment.ContentType, "image/") { continue } resp, err := http.Get(attachment.URL) if err != nil { return nil, fmt.Errorf("downloading %s: %w", attachment.Filename, err) } data, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { return nil, fmt.Errorf("downloading %s: %w", attachment.Filename, err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("downloading %s: status %s", attachment.Filename, resp.Status) } img, err := shrinkImage(output.Image{ Data: data, ContentType: attachment.ContentType, Filename: attachment.Filename, }) if err != nil { return nil, err } images = append(images, img) } return images, nil } func main() { d := discord.Discord(os.Getenv("DISCORD_TOKEN"), os.Getenv("DISCORD_CHANNEL")) tweetchannel := make(chan *discordgo.MessageCreate, 1) d.BeginRead(tweetchannel) d.Write("Tweetdistributor Started") var outputs []output.OutputInterface outputs = append(outputs, output.StdOutput()) outputs = append(outputs, output.TwitterOutput(os.Getenv("TW_ACCESS_TOKEN"), os.Getenv("TW_ACCESS_SECRET"))) outputs = append(outputs, output.BlueskyOutput(os.Getenv("BSKY_IDENTIFIER"), os.Getenv("BSKY_PASSWORD"))) for tweet := range tweetchannel { if length := utf8.RuneCountInString(tweet.Content); length > maxTweetLength { errstr := fmt.Sprintf("Error: message is %d characters, exceeding the %d character limit; not posted", length, maxTweetLength) fmt.Fprintln(os.Stderr, errstr) d.Write(errstr) continue } images, err := downloadImages(tweet.Attachments) if err != nil { errstr := fmt.Sprintf("Error: %s; not posted", err) fmt.Fprintln(os.Stderr, errstr) d.Write(errstr) continue } if len(images) > maxImagesPerPost { errstr := fmt.Sprintf("Error: %d images attached, exceeding the limit of %d; not posted", len(images), maxImagesPerPost) fmt.Fprintln(os.Stderr, errstr) d.Write(errstr) continue } for _, output := range outputs { err := output.Write(tweet.Content, images) if err != nil { errstr := fmt.Sprintf("%s Error: %s", output.GetName(), err) fmt.Fprintln(os.Stderr, errstr) d.Write(errstr) } } } }