diff --git a/go.mod b/go.mod index a19aa32..5a343cf 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,11 @@ module tweetdistributor -go 1.24.1 +go 1.25.0 require ( github.com/bluesky-social/indigo v0.0.0-20250313000755-d9a74f690c90 github.com/bwmarrin/discordgo v0.28.1 + golang.org/x/image v0.43.0 ) require ( @@ -56,7 +57,7 @@ require ( require ( github.com/gorilla/websocket v1.5.3 // indirect - github.com/michimani/gotwi v0.17.0 + github.com/michimani/gotwi v0.18.2 golang.org/x/crypto v0.36.0 // indirect golang.org/x/sys v0.31.0 // indirect ) diff --git a/main.go b/main.go index eac893b..3f3cd01 100644 --- a/main.go +++ b/main.go @@ -1,16 +1,103 @@ 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")) @@ -32,8 +119,23 @@ func main() { 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) + err := output.Write(tweet.Content, images) if err != nil { errstr := fmt.Sprintf("%s Error: %s", output.GetName(), err) fmt.Fprintln(os.Stderr, errstr) diff --git a/output/bluesky.go b/output/bluesky.go index a50f114..ad35576 100644 --- a/output/bluesky.go +++ b/output/bluesky.go @@ -1,8 +1,9 @@ package output import ( + "bytes" "context" - "log" + "fmt" "time" "github.com/bluesky-social/indigo/api/atproto" @@ -26,7 +27,7 @@ func BlueskyOutput(identifier string, password string) *blueskyoutput { return blueskyoutput } -func (bo *blueskyoutput) Write(str string) error { +func (bo *blueskyoutput) Write(str string, images []Image) error { cli := &xrpc.Client{ Host: "https://bsky.social", } @@ -38,7 +39,7 @@ func (bo *blueskyoutput) Write(str string) error { output, err := atproto.ServerCreateSession(context.TODO(), cli, input) if err != nil { - log.Fatal(err) + return err } cli.Auth = &xrpc.AuthInfo{ AccessJwt: output.AccessJwt, @@ -47,14 +48,35 @@ func (bo *blueskyoutput) Write(str string) error { Did: output.Did, } + post := &bsky.FeedPost{ + Text: str, + CreatedAt: time.Now().Format(util.ISO8601), + Langs: []string{"ja"}, + } + + if len(images) > 0 { + embedimages := make([]*bsky.EmbedImages_Image, 0, len(images)) + for _, img := range images { + blob, err := atproto.RepoUploadBlob(context.TODO(), cli, bytes.NewReader(img.Data)) + if err != nil { + return fmt.Errorf("uploading %s: %w", img.Filename, err) + } + embedimages = append(embedimages, &bsky.EmbedImages_Image{ + Alt: img.Filename, + Image: blob.Blob, + }) + } + post.Embed = &bsky.FeedPost_Embed{ + EmbedImages: &bsky.EmbedImages{ + Images: embedimages, + }, + } + } + Recordinput := &atproto.RepoCreateRecord_Input{ Collection: "app.bsky.feed.post", Repo: cli.Auth.Did, // "matope.bsky.social" のDID - Record: &lexutil.LexiconTypeDecoder{&bsky.FeedPost{ - Text: str, - CreatedAt: time.Now().Format(util.ISO8601), - Langs: []string{"ja"}, - }}, + Record: &lexutil.LexiconTypeDecoder{Val: post}, } _, recerr := atproto.RepoCreateRecord(context.TODO(), cli, Recordinput) diff --git a/output/output.go b/output/output.go index cdad68e..0377158 100644 --- a/output/output.go +++ b/output/output.go @@ -1,6 +1,12 @@ package output +type Image struct { + Data []byte + ContentType string + Filename string +} + type OutputInterface interface { - Write(string) error + Write(string, []Image) error GetName() string } diff --git a/output/stdout.go b/output/stdout.go index 00d49b9..e111d00 100644 --- a/output/stdout.go +++ b/output/stdout.go @@ -11,8 +11,11 @@ func StdOutput() *stdoutput { return &stdoutput{} } -func (so *stdoutput) Write(str string) error { +func (so *stdoutput) Write(str string, images []Image) error { fmt.Println(str) + for _, img := range images { + fmt.Printf("[image: %s (%s, %d bytes)]\n", img.Filename, img.ContentType, len(img.Data)) + } return nil } diff --git a/output/twitter.go b/output/twitter.go index 6006e1b..2136bb5 100644 --- a/output/twitter.go +++ b/output/twitter.go @@ -1,11 +1,14 @@ package output import ( + "bytes" "context" "fmt" "os" "github.com/michimani/gotwi" + "github.com/michimani/gotwi/media/upload" + uploadtypes "github.com/michimani/gotwi/media/upload/types" "github.com/michimani/gotwi/tweet/managetweet" "github.com/michimani/gotwi/tweet/managetweet/types" ) @@ -41,15 +44,61 @@ func (to *twitteroutput) newOAuth1Client(accessToken, accessSecret string) error return err } -func (to *twitteroutput) Write(str string) error { +func (to *twitteroutput) Write(str string, images []Image) error { + mediaIDs := make([]string, 0, len(images)) + for _, img := range images { + mediaID, err := to.uploadImage(img) + if err != nil { + return fmt.Errorf("uploading %s: %w", img.Filename, err) + } + mediaIDs = append(mediaIDs, mediaID) + } + p := &types.CreateInput{ Text: gotwi.String(str), } + if len(mediaIDs) > 0 { + p.Media = &types.CreateInputMedia{ + MediaIDs: mediaIDs, + } + } _, err := managetweet.Create(context.Background(), to.client, p) return err } +func (to *twitteroutput) uploadImage(img Image) (string, error) { + ctx := context.Background() + + initialized, err := upload.Initialize(ctx, to.client, &uploadtypes.InitializeInput{ + MediaCategory: uploadtypes.MediaCategoryTweetImage, + MediaType: uploadtypes.MediaType(img.ContentType), + TotalBytes: len(img.Data), + }) + if err != nil { + return "", err + } + mediaID := initialized.Data.MediaID + + _, err = upload.Append(ctx, to.client, &uploadtypes.AppendInput{ + MediaID: mediaID, + Media: bytes.NewReader(img.Data), + SegmentIndex: 0, + }) + if err != nil { + return "", err + } + + _, err = upload.Finalize(ctx, to.client, &uploadtypes.FinalizeInput{ + MediaID: mediaID, + }) + if err != nil { + return "", err + } + + return mediaID, nil +} + func (to *twitteroutput) GetName() string { return "twitter" }