package main import ( "bytes" "fmt" "image" _ "image/gif" "image/jpeg" _ "image/png" "io" "net/http" "os" "path" "strings" "tweetdistributor/discord" "tweetdistributor/output" "tweetdistributor/store" "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 } // distributor mirrors what happens on the Discord channel to every output. type distributor struct { d *discord.Client outputs []output.OutputInterface store *store.Store } // reportf logs an error and echoes it back into the Discord channel. func (dist *distributor) reportf(format string, args ...any) { errstr := fmt.Sprintf(format, args...) fmt.Fprintln(os.Stderr, errstr) dist.d.Write(errstr) } // created posts a new Discord message to every output, as a reply when the // Discord message itself was a reply to something we already distributed. func (dist *distributor) created(event discord.Event) { if length := utf8.RuneCountInString(event.Content); length > maxTweetLength { dist.reportf("Error: message is %d characters, exceeding the %d character limit; not posted", length, maxTweetLength) return } images, err := downloadImages(event.Attachments) if err != nil { dist.reportf("Error: %s; not posted", err) return } if len(images) > maxImagesPerPost { dist.reportf("Error: %d images attached, exceeding the limit of %d; not posted", len(images), maxImagesPerPost) return } // A reply to a message we never distributed becomes a top level post. var parents store.Refs if event.ReplyToID != "" { parents, _ = dist.store.Get(event.ReplyToID) } refs := store.Refs{} for _, out := range dist.outputs { post := output.Post{ Text: event.Content, Images: images, } if parent, ok := parents[out.GetName()]; ok && !parent.IsZero() { post.ReplyTo = &parent } ref, err := out.Write(post) if err != nil { dist.reportf("%s Error: %s", out.GetName(), err) continue } refs[out.GetName()] = ref } if len(refs) == 0 { return } if err := dist.store.Put(event.MessageID, refs); err != nil { dist.reportf("Error: could not remember the posts for this message: %s", err) } } // deleted removes the posts that a now deleted Discord message produced. func (dist *distributor) deleted(event discord.Event) { refs, ok := dist.store.Get(event.MessageID) if !ok { return } for _, out := range dist.outputs { ref, ok := refs[out.GetName()] if !ok { continue } if err := out.Delete(ref); err != nil { dist.reportf("%s Error: could not delete the post: %s", out.GetName(), err) } } if err := dist.store.Delete(event.MessageID); err != nil { dist.reportf("Error: could not forget the posts for this message: %s", err) } } func main() { posts, err := store.New(os.Getenv("POST_STORE")) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } d := discord.Discord(os.Getenv("DISCORD_TOKEN"), os.Getenv("DISCORD_CHANNEL")) eventchannel := make(chan discord.Event, 1) d.BeginRead(eventchannel) 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"))) dist := &distributor{d: d, outputs: outputs, store: posts} for event := range eventchannel { switch event.Kind { case discord.MessageCreated: dist.created(event) case discord.MessageDeleted: dist.deleted(event) } } }