attach image
This commit is contained in:
5
go.mod
5
go.mod
@@ -1,10 +1,11 @@
|
|||||||
module tweetdistributor
|
module tweetdistributor
|
||||||
|
|
||||||
go 1.24.1
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bluesky-social/indigo v0.0.0-20250313000755-d9a74f690c90
|
github.com/bluesky-social/indigo v0.0.0-20250313000755-d9a74f690c90
|
||||||
github.com/bwmarrin/discordgo v0.28.1
|
github.com/bwmarrin/discordgo v0.28.1
|
||||||
|
golang.org/x/image v0.43.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -56,7 +57,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
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/crypto v0.36.0 // indirect
|
||||||
golang.org/x/sys v0.31.0 // indirect
|
golang.org/x/sys v0.31.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
104
main.go
104
main.go
@@ -1,16 +1,103 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "image/gif"
|
||||||
|
"image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
"tweetdistributor/discord"
|
"tweetdistributor/discord"
|
||||||
"tweetdistributor/output"
|
"tweetdistributor/output"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
|
"golang.org/x/image/draw"
|
||||||
|
_ "golang.org/x/image/webp"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxTweetLength = 140
|
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() {
|
func main() {
|
||||||
d := discord.Discord(os.Getenv("DISCORD_TOKEN"), os.Getenv("DISCORD_CHANNEL"))
|
d := discord.Discord(os.Getenv("DISCORD_TOKEN"), os.Getenv("DISCORD_CHANNEL"))
|
||||||
@@ -32,8 +119,23 @@ func main() {
|
|||||||
d.Write(errstr)
|
d.Write(errstr)
|
||||||
continue
|
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 {
|
for _, output := range outputs {
|
||||||
err := output.Write(tweet.Content)
|
err := output.Write(tweet.Content, images)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errstr := fmt.Sprintf("%s Error: %s", output.GetName(), err)
|
errstr := fmt.Sprintf("%s Error: %s", output.GetName(), err)
|
||||||
fmt.Fprintln(os.Stderr, errstr)
|
fmt.Fprintln(os.Stderr, errstr)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
package output
|
package output
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/bluesky-social/indigo/api/atproto"
|
"github.com/bluesky-social/indigo/api/atproto"
|
||||||
@@ -26,7 +27,7 @@ func BlueskyOutput(identifier string, password string) *blueskyoutput {
|
|||||||
return blueskyoutput
|
return blueskyoutput
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bo *blueskyoutput) Write(str string) error {
|
func (bo *blueskyoutput) Write(str string, images []Image) error {
|
||||||
cli := &xrpc.Client{
|
cli := &xrpc.Client{
|
||||||
Host: "https://bsky.social",
|
Host: "https://bsky.social",
|
||||||
}
|
}
|
||||||
@@ -38,7 +39,7 @@ func (bo *blueskyoutput) Write(str string) error {
|
|||||||
|
|
||||||
output, err := atproto.ServerCreateSession(context.TODO(), cli, input)
|
output, err := atproto.ServerCreateSession(context.TODO(), cli, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
return err
|
||||||
}
|
}
|
||||||
cli.Auth = &xrpc.AuthInfo{
|
cli.Auth = &xrpc.AuthInfo{
|
||||||
AccessJwt: output.AccessJwt,
|
AccessJwt: output.AccessJwt,
|
||||||
@@ -47,14 +48,35 @@ func (bo *blueskyoutput) Write(str string) error {
|
|||||||
Did: output.Did,
|
Did: output.Did,
|
||||||
}
|
}
|
||||||
|
|
||||||
Recordinput := &atproto.RepoCreateRecord_Input{
|
post := &bsky.FeedPost{
|
||||||
Collection: "app.bsky.feed.post",
|
|
||||||
Repo: cli.Auth.Did, // "matope.bsky.social" のDID
|
|
||||||
Record: &lexutil.LexiconTypeDecoder{&bsky.FeedPost{
|
|
||||||
Text: str,
|
Text: str,
|
||||||
CreatedAt: time.Now().Format(util.ISO8601),
|
CreatedAt: time.Now().Format(util.ISO8601),
|
||||||
Langs: []string{"ja"},
|
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{Val: post},
|
||||||
}
|
}
|
||||||
_, recerr := atproto.RepoCreateRecord(context.TODO(), cli, Recordinput)
|
_, recerr := atproto.RepoCreateRecord(context.TODO(), cli, Recordinput)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
package output
|
package output
|
||||||
|
|
||||||
|
type Image struct {
|
||||||
|
Data []byte
|
||||||
|
ContentType string
|
||||||
|
Filename string
|
||||||
|
}
|
||||||
|
|
||||||
type OutputInterface interface {
|
type OutputInterface interface {
|
||||||
Write(string) error
|
Write(string, []Image) error
|
||||||
GetName() string
|
GetName() string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ func StdOutput() *stdoutput {
|
|||||||
return &stdoutput{}
|
return &stdoutput{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (so *stdoutput) Write(str string) error {
|
func (so *stdoutput) Write(str string, images []Image) error {
|
||||||
fmt.Println(str)
|
fmt.Println(str)
|
||||||
|
for _, img := range images {
|
||||||
|
fmt.Printf("[image: %s (%s, %d bytes)]\n", img.Filename, img.ContentType, len(img.Data))
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package output
|
package output
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/michimani/gotwi"
|
"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"
|
||||||
"github.com/michimani/gotwi/tweet/managetweet/types"
|
"github.com/michimani/gotwi/tweet/managetweet/types"
|
||||||
)
|
)
|
||||||
@@ -41,15 +44,61 @@ func (to *twitteroutput) newOAuth1Client(accessToken, accessSecret string) error
|
|||||||
return err
|
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{
|
p := &types.CreateInput{
|
||||||
Text: gotwi.String(str),
|
Text: gotwi.String(str),
|
||||||
}
|
}
|
||||||
|
if len(mediaIDs) > 0 {
|
||||||
|
p.Media = &types.CreateInputMedia{
|
||||||
|
MediaIDs: mediaIDs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_, err := managetweet.Create(context.Background(), to.client, p)
|
_, err := managetweet.Create(context.Background(), to.client, p)
|
||||||
return err
|
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 {
|
func (to *twitteroutput) GetName() string {
|
||||||
return "twitter"
|
return "twitter"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user