Files
tweetdistributor/main.go
sirrow 1c2ab29404 mirror Discord replies as replies on X and bluesky
Replying to a message on the watched channel now posts the reply as a
reply to the posts that message produced, instead of as a standalone
post.

Discord tells us the parent through MessageReference, and the store maps
it back to the Ref each output returned; that Ref rides along on the new
Post.ReplyTo. X takes it as in_reply_to_tweet_id. Bluesky needs the
thread root as well as the parent, so a Ref now carries the root it was
posted under and a post that is not itself a reply acts as its own root.

Replying to something we never distributed, an error notice from the bot
for instance, still posts normally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:00:22 +09:00

219 lines
5.8 KiB
Go

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)
}
}
}