A message containing a YouTube link now carries a preview card. Discord and X build their own card from the URL in the text, so the work is on the bluesky side: the new preview.go asks YouTube's oEmbed endpoint for the title, channel and thumbnail and posts them as an app.bsky.embed.external. Links are found by pulling candidates out of the text and parsing them with net/url rather than by matching a URL shaped regexp, so a host like youtube.com.example.invalid is not mistaken for the real thing. Bluesky allows one embed per post, so a message with both attachments and a link keeps the attachments. A failed oEmbed lookup only costs the card, not the post. Downloading the thumbnail wants the attachment download path, so it is split into fetch and downloadImage, and both now go through a client with a timeout: the event loop is single threaded and a hung request would stall every later message. The Dockerfile built main.go by name, which stops working now that package main spans two files; it builds the package instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
257 lines
6.8 KiB
Go
257 lines
6.8 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"image"
|
|
_ "image/gif"
|
|
"image/jpeg"
|
|
_ "image/png"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
"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
|
|
|
|
// urlPattern picks candidate links out of message text; each one is parsed
|
|
// properly before it is judged to be a YouTube link.
|
|
var urlPattern = regexp.MustCompile(`https?://[^\s<>"']+`)
|
|
|
|
// trimURL drops the punctuation that ends the sentence rather than the URL.
|
|
func trimURL(match string) string {
|
|
return strings.TrimRight(match, ".,!?、。)]}>")
|
|
}
|
|
|
|
var httpClient = &http.Client{Timeout: 30 * time.Second}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// fetch GETs url and returns its body.
|
|
func fetch(url string) ([]byte, error) {
|
|
resp, err := httpClient.Get(url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("status %s", resp.Status)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// downloadImage fetches an image and shrinks it to a postable size.
|
|
func downloadImage(url, filename, contentType string) (output.Image, error) {
|
|
data, err := fetch(url)
|
|
if err != nil {
|
|
return output.Image{}, fmt.Errorf("downloading %s: %w", filename, err)
|
|
}
|
|
return shrinkImage(output.Image{
|
|
Data: data,
|
|
ContentType: contentType,
|
|
Filename: filename,
|
|
})
|
|
}
|
|
|
|
func downloadImages(attachments []*discordgo.MessageAttachment) ([]output.Image, error) {
|
|
var images []output.Image
|
|
for _, attachment := range attachments {
|
|
if !strings.HasPrefix(attachment.ContentType, "image/") {
|
|
continue
|
|
}
|
|
img, err := downloadImage(attachment.URL, attachment.Filename, attachment.ContentType)
|
|
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
|
|
}
|
|
|
|
var preview *output.Preview
|
|
if videoURL := findYouTubeURL(event.Content); videoURL != "" {
|
|
preview, err = youtubePreview(videoURL)
|
|
if err != nil {
|
|
// The post is still worth making without its card.
|
|
fmt.Fprintln(os.Stderr, err)
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
Preview: preview,
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|